chore: API and frontend UI adjustments
- API: issue label bulk add, search messages, room WS push, openapi - Frontend: notify page, issue detail AI triage banner, search page, repository settings, preferences, PR components, file browser - Room: DiscordChannelSidebar, RoomPinPanel, RoomMessageActions, RoomThreadPanel, MessageContent, repository-context - Frontend SDK regenerated from openapi.json
This commit is contained in:
parent
dfa5f7664a
commit
99bc4eeb80
@ -1,4 +1,5 @@
|
|||||||
pub mod code_review;
|
pub mod code_review;
|
||||||
|
pub mod issue_triage;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod model_capability;
|
pub mod model_capability;
|
||||||
pub mod model_parameter_profile;
|
pub mod model_parameter_profile;
|
||||||
@ -16,6 +17,10 @@ pub fn init_agent_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
"/code-review/{namespace}/{repo}",
|
"/code-review/{namespace}/{repo}",
|
||||||
web::post().to(code_review::trigger_code_review),
|
web::post().to(code_review::trigger_code_review),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{project}/issues/{issue_number}/triage",
|
||||||
|
web::get().to(issue_triage::triage_issue),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/pr-description/{namespace}/{repo}",
|
"/pr-description/{namespace}/{repo}",
|
||||||
web::post().to(pr_summary::generate_pr_description),
|
web::post().to(pr_summary::generate_pr_description),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::{ApiResponse, error::ApiError};
|
use crate::{ApiResponse, error::ApiError};
|
||||||
use actix_web::{HttpResponse, Result, web};
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
use service::issue::IssueAddLabelsByNamesRequest;
|
||||||
use service::AppService;
|
use service::AppService;
|
||||||
use session::Session;
|
use session::Session;
|
||||||
|
|
||||||
@ -85,3 +86,32 @@ pub async fn issue_label_remove(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
|
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",
|
"/issues/{number}/labels",
|
||||||
web::post().to(issue_label::issue_label_add),
|
web::post().to(issue_label::issue_label_add),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/issues/{number}/labels/bulk",
|
||||||
|
web::post().to(issue_label::issue_label_add_bulk),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/issues/{number}/labels/{label_id}",
|
"/issues/{number}/labels/{label_id}",
|
||||||
web::delete().to(issue_label::issue_label_remove),
|
web::delete().to(issue_label::issue_label_remove),
|
||||||
|
|||||||
@ -36,6 +36,7 @@ use utoipa::OpenApi;
|
|||||||
crate::admin::alerts::admin_check_alerts,
|
crate::admin::alerts::admin_check_alerts,
|
||||||
// Agent (CRUD)
|
// Agent (CRUD)
|
||||||
crate::agent::code_review::trigger_code_review,
|
crate::agent::code_review::trigger_code_review,
|
||||||
|
crate::agent::issue_triage::triage_issue,
|
||||||
crate::agent::pr_summary::generate_pr_description,
|
crate::agent::pr_summary::generate_pr_description,
|
||||||
crate::agent::provider::provider_list,
|
crate::agent::provider::provider_list,
|
||||||
crate::agent::provider::provider_get,
|
crate::agent::provider::provider_get,
|
||||||
@ -247,6 +248,7 @@ use utoipa::OpenApi;
|
|||||||
crate::issue::issue_summary,
|
crate::issue::issue_summary,
|
||||||
crate::issue::issue_label::issue_label_list,
|
crate::issue::issue_label::issue_label_list,
|
||||||
crate::issue::issue_label::issue_label_add,
|
crate::issue::issue_label::issue_label_add,
|
||||||
|
crate::issue::issue_label::issue_label_add_bulk,
|
||||||
crate::issue::issue_label::issue_label_remove,
|
crate::issue::issue_label::issue_label_remove,
|
||||||
crate::issue::label::label_list,
|
crate::issue::label::label_list,
|
||||||
crate::issue::label::label_create,
|
crate::issue::label::label_create,
|
||||||
@ -403,6 +405,7 @@ use utoipa::OpenApi;
|
|||||||
crate::room::draft_and_history::mention_read_all,
|
crate::room::draft_and_history::mention_read_all,
|
||||||
// Search
|
// Search
|
||||||
crate::search::service::search,
|
crate::search::service::search,
|
||||||
|
crate::search::service::search_messages,
|
||||||
crate::room::reaction::message_search,
|
crate::room::reaction::message_search,
|
||||||
// User
|
// User
|
||||||
crate::user::profile::get_my_profile,
|
crate::user::profile::get_my_profile,
|
||||||
@ -482,6 +485,7 @@ use utoipa::OpenApi;
|
|||||||
service::issue::IssueCommentListResponse,
|
service::issue::IssueCommentListResponse,
|
||||||
service::issue::IssueLabelResponse,
|
service::issue::IssueLabelResponse,
|
||||||
service::issue::IssueAddLabelRequest,
|
service::issue::IssueAddLabelRequest,
|
||||||
|
service::issue::IssueAddLabelsByNamesRequest,
|
||||||
service::issue::LabelResponse,
|
service::issue::LabelResponse,
|
||||||
service::issue::CreateLabelRequest,
|
service::issue::CreateLabelRequest,
|
||||||
service::issue::ReactionAddRequest,
|
service::issue::ReactionAddRequest,
|
||||||
@ -585,6 +589,8 @@ use utoipa::OpenApi;
|
|||||||
service::agent::code_review::TriggerCodeReviewRequest,
|
service::agent::code_review::TriggerCodeReviewRequest,
|
||||||
service::agent::code_review::TriggerCodeReviewResponse,
|
service::agent::code_review::TriggerCodeReviewResponse,
|
||||||
service::agent::code_review::CommentCreated,
|
service::agent::code_review::CommentCreated,
|
||||||
|
service::agent::issue_triage::IssueTriageSuggestion,
|
||||||
|
service::agent::issue_triage::IssueTriageResponse,
|
||||||
service::agent::pr_summary::GeneratePrDescriptionRequest,
|
service::agent::pr_summary::GeneratePrDescriptionRequest,
|
||||||
service::agent::pr_summary::GeneratePrDescriptionResponse,
|
service::agent::pr_summary::GeneratePrDescriptionResponse,
|
||||||
service::agent::provider::ProviderResponse,
|
service::agent::provider::ProviderResponse,
|
||||||
@ -715,6 +721,8 @@ use utoipa::OpenApi;
|
|||||||
service::search::RepoSearchItem,
|
service::search::RepoSearchItem,
|
||||||
service::search::IssueSearchItem,
|
service::search::IssueSearchItem,
|
||||||
service::search::UserSearchItem,
|
service::search::UserSearchItem,
|
||||||
|
service::search::GlobalMessageSearchResponse,
|
||||||
|
service::search::GlobalMessageSearchItem,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
|
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
|
||||||
|
use room::types::NotificationEvent;
|
||||||
use room::connection::RoomConnectionManager;
|
use room::connection::RoomConnectionManager;
|
||||||
use service::AppService;
|
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 MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1);
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WsPushEvent {
|
pub enum WsPushEvent {
|
||||||
RoomMessage {
|
RoomMessage {
|
||||||
@ -44,6 +45,9 @@ pub enum WsPushEvent {
|
|||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
event: Arc<TypingEvent>,
|
event: Arc<TypingEvent>,
|
||||||
},
|
},
|
||||||
|
Notification {
|
||||||
|
event: Arc<NotificationEvent>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream)
|
/// 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_active.increment(1.0);
|
||||||
manager.metrics.ws_connections_total.increment(1);
|
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)?;
|
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?;
|
||||||
actix::spawn(async move {
|
actix::spawn(async move {
|
||||||
let handler = WsRequestHandler::new(Arc::new(service), user_id);
|
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;
|
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
|
||||||
break;
|
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) => {
|
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
|
||||||
match push_event {
|
match push_event {
|
||||||
Some(WsPushEvent::RoomMessage { room_id, event }) => {
|
Some(WsPushEvent::RoomMessage { room_id, event }) => {
|
||||||
@ -268,6 +300,9 @@ pub async fn ws_universal(
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
}
|
}
|
||||||
|
Some(WsPushEvent::Notification { .. }) => {
|
||||||
|
// Notification events are handled via the notif_stream branch above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
msg = msg_stream.recv() => {
|
msg = msg_stream.recv() => {
|
||||||
|
|||||||
@ -4,4 +4,5 @@ use actix_web::web;
|
|||||||
|
|
||||||
pub fn init_search_routes(cfg: &mut web::ServiceConfig) {
|
pub fn init_search_routes(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.route("/search", web::to(service::search));
|
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 crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Result, web};
|
use actix_web::{HttpResponse, Result, web};
|
||||||
use service::AppService;
|
use service::AppService;
|
||||||
use service::search::{SearchQuery, SearchResponse};
|
use service::search::{GlobalMessageSearchQuery, GlobalMessageSearchResponse, SearchQuery, SearchResponse};
|
||||||
use session::Session;
|
use session::Session;
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@ -29,3 +29,27 @@ pub async fn search(
|
|||||||
let resp = service.search(&session, query.into_inner()).await?;
|
let resp = service.search(&session, query.into_inner()).await?;
|
||||||
Ok(ApiResponse::ok(resp).to_response())
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@ -267,7 +267,10 @@ impl RoomService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
notification: super::NotificationResponse,
|
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
|
self.room_manager
|
||||||
.push_user_notification(user_id, Arc::new(event))
|
.push_user_notification(user_id, Arc::new(event))
|
||||||
.await;
|
.await;
|
||||||
@ -278,7 +281,7 @@ impl RoomService {
|
|||||||
user_id,
|
user_id,
|
||||||
notification.title.clone(),
|
notification.title.clone(),
|
||||||
notification.content.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()
|
||||||
|
}
|
||||||
|
|||||||
@ -369,6 +369,7 @@ pub struct NotificationListResponse {
|
|||||||
pub struct NotificationEvent {
|
pub struct NotificationEvent {
|
||||||
pub event_type: String,
|
pub event_type: String,
|
||||||
pub notification: NotificationResponse,
|
pub notification: NotificationResponse,
|
||||||
|
pub deep_link_url: Option<String>,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,7 +378,13 @@ impl NotificationEvent {
|
|||||||
Self {
|
Self {
|
||||||
event_type: "notification_created".into(),
|
event_type: "notification_created".into(),
|
||||||
notification,
|
notification,
|
||||||
|
deep_link_url: None,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_deep_link(mut self, url: String) -> Self {
|
||||||
|
self.deep_link_url = Some(url);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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": {
|
"/api/auth/captcha": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"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}": {
|
"/api/issue/{project}/issues/{number}/labels/{label_id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"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": {
|
"/api/users/me/access-keys": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"ApiResponse_InvitationListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"ApiResponse_JoinAnswersListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -34653,6 +34907,12 @@
|
|||||||
"string",
|
"string",
|
||||||
"null"
|
"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": {
|
"InvitationListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -34811,6 +35163,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"IssueAddLabelsByNamesRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"names"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"names": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"IssueAssignUserRequest": {
|
"IssueAssignUserRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"IssueUpdateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -37453,7 +37862,8 @@
|
|||||||
"star_count",
|
"star_count",
|
||||||
"watch_count",
|
"watch_count",
|
||||||
"ssh_clone_url",
|
"ssh_clone_url",
|
||||||
"https_clone_url"
|
"https_clone_url",
|
||||||
|
"ai_code_review_enabled"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"uid": {
|
"uid": {
|
||||||
@ -37510,6 +37920,9 @@
|
|||||||
},
|
},
|
||||||
"https_clone_url": {
|
"https_clone_url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ai_code_review_enabled": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import { useState } from "react";
|
'use client';
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { toast } from "sonner";
|
/**
|
||||||
|
* 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 {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
AtSign,
|
AtSign,
|
||||||
@ -8,29 +18,109 @@ import {
|
|||||||
BellOff,
|
BellOff,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
GitPullRequest,
|
||||||
import { notificationList, notificationMarkRead, notificationMarkAllRead, notificationArchive } from "@/client";
|
CheckCircle,
|
||||||
import type { NotificationResponse } from "@/client";
|
Merge,
|
||||||
import { Button } from "@/components/ui/button";
|
AlertCircle,
|
||||||
import { Badge } from "@/components/ui/badge";
|
SlidersHorizontal,
|
||||||
import {getApiErrorMessage} from '@/lib/api-error';
|
} 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 Filter = 'all' | 'unread' | 'archived';
|
||||||
|
type GroupBy = 'none' | 'project' | 'type';
|
||||||
|
|
||||||
const NOTIFICATION_TYPE_CONFIG: Record<
|
const NOTIFICATION_TYPE_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; icon: React.ReactNode; color: 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" },
|
mention: {
|
||||||
invitation: { label: "Invitation", icon: <Mail className="h-3.5 w-3.5" />, color: "bg-purple-500/10 text-purple-600 border-purple-500/20" },
|
label: 'Mention',
|
||||||
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" },
|
icon: <AtSign className="h-3.5 w-3.5" />,
|
||||||
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" },
|
color: 'bg-blue-500/10 text-blue-600 border-blue-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" },
|
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 {
|
function formatTime(dateStr: string): string {
|
||||||
@ -38,7 +128,7 @@ function formatTime(dateStr: string): string {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - d.getTime();
|
const diff = now.getTime() - d.getTime();
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
if (minutes < 1) return "just now";
|
if (minutes < 1) return 'just now';
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return `${hours}h ago`;
|
||||||
@ -47,30 +137,30 @@ function formatTime(dateStr: string): string {
|
|||||||
return d.toLocaleDateString();
|
return d.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationItem({
|
function getTypeLabel(type: string): string {
|
||||||
n,
|
return NOTIFICATION_TYPE_CONFIG[type]?.label ?? type;
|
||||||
onMarkRead,
|
}
|
||||||
onArchive,
|
|
||||||
}: {
|
interface NotificationItemProps {
|
||||||
n: NotificationResponse;
|
n: NotificationResponse;
|
||||||
onMarkRead: (id: string) => void;
|
onMarkRead: (id: string) => void;
|
||||||
onArchive: (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] ?? {
|
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
|
||||||
label: n.notification_type,
|
label: n.notification_type,
|
||||||
icon: <Bell className="h-3.5 w-3.5" />,
|
icon: <Bell className="h-3.5 w-3.5" />,
|
||||||
color: "bg-muted text-muted-foreground border-border",
|
color: 'bg-muted text-muted-foreground border-border',
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!n.is_read) onMarkRead(n.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0 ${
|
className={cn(
|
||||||
!n.is_read ? "bg-primary/5" : ""
|
'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 */}
|
{/* Unread dot */}
|
||||||
<div className="flex-shrink-0 pt-1">
|
<div className="flex-shrink-0 pt-1">
|
||||||
@ -78,19 +168,26 @@ function NotificationItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className={`flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center ${config.color}`}>
|
<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}
|
{config.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={() => {
|
||||||
className="text-left flex-1 min-w-0"
|
if (!n.is_read) onMarkRead(n.id);
|
||||||
|
onNavigate(n);
|
||||||
|
}}
|
||||||
|
className="text-left flex-1 min-w-0 w-full"
|
||||||
>
|
>
|
||||||
<p className={`text-sm truncate ${!n.is_read ? "font-semibold" : "font-medium"}`}>
|
<p className={cn('text-sm truncate', !n.is_read ? 'font-semibold' : 'font-medium')}>
|
||||||
{n.title}
|
{n.title}
|
||||||
</p>
|
</p>
|
||||||
{n.content && (
|
{n.content && (
|
||||||
@ -99,16 +196,13 @@ function NotificationItem({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<Badge variant="outline" className={`text-xs ${config.color} border`}>
|
<Badge variant="outline" className={cn('text-xs border', config.color)}>
|
||||||
{config.label}
|
{config.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{formatTime(n.created_at)}</span>
|
||||||
{formatTime(n.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
@ -117,7 +211,10 @@ function NotificationItem({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
onClick={(e) => { e.stopPropagation(); onMarkRead(n.id); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkRead(n.id);
|
||||||
|
}}
|
||||||
title="Mark as read"
|
title="Mark as read"
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@ -127,7 +224,10 @@ function NotificationItem({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => { e.stopPropagation(); onArchive(n.id); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onArchive(n.id);
|
||||||
|
}}
|
||||||
title="Archive"
|
title="Archive"
|
||||||
>
|
>
|
||||||
<Archive className="h-3.5 w-3.5" />
|
<Archive className="h-3.5 w-3.5" />
|
||||||
@ -137,67 +237,188 @@ function NotificationItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationGroupProps {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function NotifyPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [filter, setFilter] = useState<Filter>("all");
|
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({
|
const {
|
||||||
queryKey: ["notifications", filter],
|
notifications,
|
||||||
queryFn: async () => {
|
unreadCount,
|
||||||
const resp = await notificationList({
|
markRead,
|
||||||
query: {
|
markAllRead,
|
||||||
only_unread: filter === "unread",
|
archive,
|
||||||
archived: filter === "archived" ? true : undefined,
|
isLive,
|
||||||
limit: 100,
|
} = useNotification({
|
||||||
},
|
showToast: false,
|
||||||
});
|
|
||||||
return resp.data?.data ?? null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const markReadMutation = useMutation({
|
const filteredNotifications = notifications.filter((n) => {
|
||||||
mutationFn: async (notificationId: string) => {
|
if (filter === 'unread') return !n.is_read;
|
||||||
await notificationMarkRead({ path: { notification_id: notificationId } });
|
if (filter === 'archived') return n.is_archived;
|
||||||
},
|
return !n.is_archived;
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
toast.error(getApiErrorMessage(err, "Failed to mark as read"));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const markAllReadMutation = useMutation({
|
const handleMarkRead = async (id: string) => {
|
||||||
mutationFn: async () => {
|
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();
|
await notificationMarkAllRead();
|
||||||
},
|
markAllRead();
|
||||||
onSuccess: () => {
|
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
toast.success("All notifications marked as read");
|
} catch (err) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
toast.error(getApiErrorMessage(err, 'Failed to mark all as read'));
|
||||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
}
|
||||||
},
|
};
|
||||||
onError: (err: unknown) => {
|
|
||||||
toast.error(getApiErrorMessage(err, "Failed to mark all as read"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const handleNavigate = (n: NotificationResponse) => {
|
||||||
mutationFn: async (notificationId: string) => {
|
// Determine navigation target from notification metadata
|
||||||
await notificationArchive({ path: { notification_id: notificationId } });
|
if (n.related_room_id && n.project) {
|
||||||
},
|
navigate(`/project/${n.project}/room`);
|
||||||
onSuccess: () => {
|
} else if (n.notification_type.includes('invitation')) {
|
||||||
toast.success("Notification archived");
|
navigate('/invitations');
|
||||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
} else if (n.notification_type.startsWith('issue')) {
|
||||||
},
|
// Navigate to project/issue if project is known
|
||||||
onError: (err: unknown) => {
|
if (n.project) {
|
||||||
toast.error(getApiErrorMessage(err, "Failed to archive"));
|
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 notifications: NotificationResponse[] = data?.notifications ?? [];
|
const toggleSelect = (id: string) => {
|
||||||
const total: number = data?.total ?? 0;
|
setSelectedIds((prev) => {
|
||||||
const unreadCount: number = data?.unread_count ?? 0;
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 handleMarkReadForGroup = async (ids: string[]) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
await notificationMarkRead({ path: { notification_id: id } });
|
||||||
|
markRead(id);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 }];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const total = filteredNotifications.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||||
@ -207,21 +428,75 @@ export default function NotifyPage() {
|
|||||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
Notifications
|
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>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
{unreadCount > 0
|
{unreadCount > 0
|
||||||
? `${unreadCount} unread · ${total} total`
|
? `${unreadCount} unread · ${total} total`
|
||||||
: `${total} notification${total !== 1 ? "s" : ""}`}
|
: `${total} notification${total !== 1 ? 's' : ''}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{unreadCount > 0 && filter !== "archived" && (
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => markAllReadMutation.mutate()}
|
onClick={() => {
|
||||||
disabled={markAllReadMutation.isPending}
|
setSelectedIds(new Set());
|
||||||
|
setBatchMode(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{markAllReadMutation.isPending ? (
|
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" />
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
|
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
|
||||||
@ -229,60 +504,144 @@ export default function NotifyPage() {
|
|||||||
Mark all read
|
Mark all read
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* Filter tabs */}
|
||||||
<div className="flex items-center gap-1 border-b">
|
<div className="flex items-center gap-1 border-b">
|
||||||
{(["all", "unread", "archived"] as Filter[]).map((f) => (
|
{(['all', 'unread', 'archived'] as Filter[]).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={cn(
|
||||||
|
'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
filter === f
|
filter === f
|
||||||
? "border-primary text-primary"
|
? 'border-primary text-primary'
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{f === "all" ? "All" : f === "unread" ? "Unread" : "Archived"}
|
{f === 'all' ? 'All' : f === 'unread' ? 'Unread' : 'Archived'}
|
||||||
{f === "unread" && unreadCount > 0 && (
|
{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">
|
<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}
|
{unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{total === 0 ? (
|
||||||
<div className="border rounded-lg bg-card overflow-hidden">
|
<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">
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||||
<BellOff className="h-10 w-10 mb-3 opacity-40" />
|
<BellOff className="h-10 w-10 mb-3 opacity-40" />
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{filter === "unread" ? "No unread notifications" : filter === "archived" ? "No archived notifications" : "No notifications yet"}
|
{filter === 'unread'
|
||||||
|
? 'No unread notifications'
|
||||||
|
: filter === 'archived'
|
||||||
|
? 'No archived notifications'
|
||||||
|
: 'No notifications yet'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
{filter === "unread"
|
{filter === 'unread'
|
||||||
? "You're all caught up!"
|
? "You're all caught up!"
|
||||||
: filter === "archived"
|
: filter === 'archived'
|
||||||
? "Archived notifications will appear here."
|
? 'Archived notifications will appear here.'
|
||||||
: "You'll see notifications here when something happens."}
|
: "You'll see notifications here when something happens."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
notifications.map((n) => (
|
) : groupBy !== 'none' ? (
|
||||||
<NotificationItem
|
groups.map(({ title, items }) => (
|
||||||
key={n.id}
|
<NotificationGroup key={title} title={title} count={items.length}>
|
||||||
n={n}
|
{items.map((n) => (
|
||||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
<div key={n.id} className="flex items-start">
|
||||||
onArchive={(id) => archiveMutation.mutate(id)}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
Edit,
|
Edit,
|
||||||
|
Lightbulb,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -19,15 +22,20 @@ import {
|
|||||||
issueCommentList,
|
issueCommentList,
|
||||||
issueCommentUpdate,
|
issueCommentUpdate,
|
||||||
issueGet,
|
issueGet,
|
||||||
|
issueLabelAddBulk,
|
||||||
issueReopen,
|
issueReopen,
|
||||||
issueRepoList,
|
issueRepoList,
|
||||||
|
triageIssue,
|
||||||
} from '@/client';
|
} from '@/client';
|
||||||
import type { IssueCommentResponse } from '@/client/types.gen';
|
import type { IssueCommentResponse } from '@/client/types.gen';
|
||||||
import { useProject } from '@/contexts';
|
import { useProject } from '@/contexts';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {getApiErrorMessage} from '@/lib/api-error';
|
import {getApiErrorMessage} from '@/lib/api-error';
|
||||||
|
import { ContentRenderer } from '@/components/shared/ContentRenderer';
|
||||||
|
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
|
||||||
|
|
||||||
const StatusBadge = ({ status }: { status: string }) => {
|
const StatusBadge = ({ status }: { status: string }) => {
|
||||||
const styles =
|
const styles =
|
||||||
@ -62,6 +70,9 @@ export function IssueDetail() {
|
|||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editingContent, setEditingContent] = useState('');
|
const [editingContent, setEditingContent] = useState('');
|
||||||
|
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const { typingUsers } = useTypingIndicator({});
|
||||||
|
|
||||||
const no = Number(issueNumber || 0);
|
const no = Number(issueNumber || 0);
|
||||||
|
|
||||||
@ -112,6 +123,37 @@ export function IssueDetail() {
|
|||||||
|
|
||||||
const linkedRepos = (linkedReposRaw as { repos?: { repo: string; repo_name: string }[] })?.repos ?? [];
|
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[] = [];
|
const timeline: TimelineItem[] = [];
|
||||||
|
|
||||||
if (issue) {
|
if (issue) {
|
||||||
@ -273,9 +315,72 @@ export function IssueDetail() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
{issue.body && (
|
{issue.body && (
|
||||||
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
|
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
|
||||||
<p className="text-sm whitespace-pre-wrap text-foreground/90 leading-relaxed">
|
<ContentRenderer content={issue.body} />
|
||||||
{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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -395,9 +500,7 @@ export function IssueDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
<ContentRenderer content={comment.body} />
|
||||||
{comment.body}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -415,7 +518,12 @@ export function IssueDetail() {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={newComment}
|
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…"
|
placeholder="Leave a comment…"
|
||||||
rows={4}
|
rows={4}
|
||||||
className="resize-none mb-3 text-sm"
|
className="resize-none mb-3 text-sm"
|
||||||
@ -423,10 +531,8 @@ export function IssueDetail() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!newComment.trim()) {
|
if (!newComment.trim()) { toast.error('Comment cannot be empty'); return; }
|
||||||
toast.error('Comment cannot be empty');
|
if (typingStopTimer.current) { clearTimeout(typingStopTimer.current); typingStopTimer.current = null; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
createCommentMutation.mutate(newComment.trim());
|
createCommentMutation.mutate(newComment.trim());
|
||||||
}}
|
}}
|
||||||
disabled={createCommentMutation.isPending || !newComment.trim()}
|
disabled={createCommentMutation.isPending || !newComment.trim()}
|
||||||
@ -434,6 +540,33 @@ export function IssueDetail() {
|
|||||||
{createCommentMutation.isPending ? 'Posting…' : 'Comment'}
|
{createCommentMutation.isPending ? 'Posting…' : 'Comment'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Edit2,
|
Edit2,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Bot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 ?? [];
|
const branches: Array<{ name: string }> = branchesData ?? [];
|
||||||
|
|
||||||
if (!repo) return null;
|
if (!repo) return null;
|
||||||
@ -280,6 +299,34 @@ export function RepoSettingsGeneral() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
Users,
|
Users,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { client } from '@/client/client.gen';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -21,12 +23,16 @@ import type {
|
|||||||
RepoSearchItem,
|
RepoSearchItem,
|
||||||
IssueSearchItem,
|
IssueSearchItem,
|
||||||
UserSearchItem,
|
UserSearchItem,
|
||||||
|
MessageSearchResponse,
|
||||||
|
RoomMessageResponse,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
|
GlobalMessageSearchResponse,
|
||||||
|
GlobalMessageSearchItem,
|
||||||
} from '@/client/types.gen';
|
} from '@/client/types.gen';
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── 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];
|
type SearchType = typeof ALL_TYPES[number];
|
||||||
|
|
||||||
const TYPE_LABELS: Record<SearchType, string> = {
|
const TYPE_LABELS: Record<SearchType, string> = {
|
||||||
@ -34,6 +40,7 @@ const TYPE_LABELS: Record<SearchType, string> = {
|
|||||||
repos: 'Repositories',
|
repos: 'Repositories',
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
messages: 'Messages',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>> = {
|
const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>> = {
|
||||||
@ -41,6 +48,7 @@ const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>
|
|||||||
repos: FolderGit2,
|
repos: FolderGit2,
|
||||||
issues: GitPullRequest,
|
issues: GitPullRequest,
|
||||||
users: Users,
|
users: Users,
|
||||||
|
messages: MessageSquare,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTotal(results: SearchResponse): number {
|
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>({
|
function ResultSection<T>({
|
||||||
type,
|
type,
|
||||||
result,
|
result,
|
||||||
@ -192,6 +244,7 @@ function ResultSection<T>({
|
|||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [roomIdInput, setRoomIdInput] = useState('');
|
||||||
|
|
||||||
const q = searchParams.get('q') ?? '';
|
const q = searchParams.get('q') ?? '';
|
||||||
const typeParam = searchParams.get('type') ?? '';
|
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)))
|
? (typeParam.split(',').filter((t): t is SearchType => ALL_TYPES.includes(t as SearchType)))
|
||||||
: [...ALL_TYPES];
|
: [...ALL_TYPES];
|
||||||
|
|
||||||
|
const showMessages = activeTypes.includes('messages');
|
||||||
|
const useRoomScoped = roomIdInput.trim().length > 0;
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['search', q, typeParam, page],
|
queryKey: ['search', q, typeParam, page],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -220,6 +276,34 @@ export default function SearchPage() {
|
|||||||
enabled: q.trim().length > 0,
|
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;
|
const results = data ?? null;
|
||||||
|
|
||||||
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
|
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@ -280,12 +364,24 @@ export default function SearchPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||||
{isLoading && (
|
{isLoading && !showMessages && (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@ -310,8 +406,10 @@ export default function SearchPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{getTotal(results) > 0
|
{results && getTotal(results) > 0
|
||||||
? `${getTotal(results)} results for "${q}"`
|
? `${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}"`}
|
: `No results for "${q}"`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -345,6 +443,59 @@ export default function SearchPage() {
|
|||||||
renderer={(item) => <UserItem item={item} />}
|
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>
|
</div>
|
||||||
|
|
||||||
{getTotal(results) === 0 && (
|
{getTotal(results) === 0 && (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -36,6 +36,12 @@ export function SettingsPreferences() {
|
|||||||
const [securityEnabled, setSecurityEnabled] = useState(true);
|
const [securityEnabled, setSecurityEnabled] = useState(true);
|
||||||
const [productEnabled, setProductEnabled] = 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
|
// Fetch notification preferences
|
||||||
const { data: preferences, isLoading } = useQuery({
|
const { data: preferences, isLoading } = useQuery({
|
||||||
queryKey: ['notificationPreferences'],
|
queryKey: ['notificationPreferences'],
|
||||||
@ -322,6 +328,67 @@ export function SettingsPreferences() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -336,6 +403,10 @@ export function SettingsPreferences() {
|
|||||||
setMarketingEnabled(preferences.marketing_enabled ?? false);
|
setMarketingEnabled(preferences.marketing_enabled ?? false);
|
||||||
setSecurityEnabled(preferences.security_enabled ?? true);
|
setSecurityEnabled(preferences.security_enabled ?? true);
|
||||||
setProductEnabled(preferences.product_enabled ?? true);
|
setProductEnabled(preferences.product_enabled ?? true);
|
||||||
|
setIssueActivityEnabled(true);
|
||||||
|
setPrActivityEnabled(true);
|
||||||
|
setMentionActivityEnabled(true);
|
||||||
|
setChatActivityEnabled(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={updatePreferencesMutation.isPending}
|
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 = {
|
export type ApiResponseInvitationListResponse = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
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 = {
|
export type ApiResponseJoinAnswersListResponse = {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -3374,6 +3395,7 @@ export type GitReadmeResponse = {
|
|||||||
|
|
||||||
export type GitUpdateRepoRequest = {
|
export type GitUpdateRepoRequest = {
|
||||||
default_branch?: string | null;
|
default_branch?: string | null;
|
||||||
|
ai_code_review_enabled?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitWatchRequest = {
|
export type GitWatchRequest = {
|
||||||
@ -3381,6 +3403,27 @@ export type GitWatchRequest = {
|
|||||||
notify_email?: boolean;
|
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 = {
|
export type InvitationListResponse = {
|
||||||
invitations: Array<InvitationResponse>;
|
invitations: Array<InvitationResponse>;
|
||||||
total: number;
|
total: number;
|
||||||
@ -3419,6 +3462,10 @@ export type IssueAddLabelRequest = {
|
|||||||
label_id: number;
|
label_id: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IssueAddLabelsByNamesRequest = {
|
||||||
|
names: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type IssueAssignUserRequest = {
|
export type IssueAssignUserRequest = {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
};
|
};
|
||||||
@ -3540,6 +3587,17 @@ export type IssueSummaryResponse = {
|
|||||||
closed: number;
|
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 = {
|
export type IssueUpdateRequest = {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
@ -4141,6 +4199,7 @@ export type ProjectRepositoryItem = {
|
|||||||
last_commit_at?: string | null;
|
last_commit_at?: string | null;
|
||||||
ssh_clone_url: string;
|
ssh_clone_url: string;
|
||||||
https_clone_url: string;
|
https_clone_url: string;
|
||||||
|
ai_code_review_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectRepositoryPagination = {
|
export type ProjectRepositoryPagination = {
|
||||||
@ -6155,6 +6214,43 @@ export type ModelPricingListResponses = {
|
|||||||
|
|
||||||
export type ModelPricingListResponse = ModelPricingListResponses[keyof 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 = {
|
export type ApiAuthCaptchaData = {
|
||||||
body: CaptchaQuery;
|
body: CaptchaQuery;
|
||||||
path?: never;
|
path?: never;
|
||||||
@ -7287,6 +7383,40 @@ export type IssueLabelAddResponses = {
|
|||||||
|
|
||||||
export type IssueLabelAddResponse = IssueLabelAddResponses[keyof 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 = {
|
export type IssueLabelRemoveData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@ -17619,6 +17749,46 @@ export type SearchResponses = {
|
|||||||
|
|
||||||
export type SearchResponse2 = SearchResponses[keyof 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 = {
|
export type ListAccessKeysData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Loader2, Send, Eye, Edit2 } from "lucide-react";
|
import { Loader2, Send, Eye, Edit2 } from "lucide-react";
|
||||||
@ -22,6 +22,10 @@ interface PRCommentInputProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Minimum height of the textarea */
|
/** Minimum height of the textarea */
|
||||||
minRows?: number;
|
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({
|
export function PRCommentInput({
|
||||||
@ -34,14 +38,30 @@ export function PRCommentInput({
|
|||||||
onCancel,
|
onCancel,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
minRows = 3,
|
minRows = 3,
|
||||||
|
onTypingStart,
|
||||||
|
onTypingStop,
|
||||||
}: PRCommentInputProps) {
|
}: PRCommentInputProps) {
|
||||||
const [body, setBody] = useState(initialValue);
|
const [body, setBody] = useState(initialValue);
|
||||||
const [isPreview, setIsPreview] = useState(false);
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = 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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!body.trim()) return;
|
if (!body.trim()) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; }
|
||||||
|
if (onTypingStop) onTypingStop();
|
||||||
try {
|
try {
|
||||||
await onSubmit(body.trim());
|
await onSubmit(body.trim());
|
||||||
setBody("");
|
setBody("");
|
||||||
@ -91,7 +111,7 @@ export function PRCommentInput({
|
|||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => handleTypingChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={minRows}
|
rows={minRows}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import {
|
|||||||
} from "@/client";
|
} from "@/client";
|
||||||
import { PRCommentInput } from "./PRCommentInput";
|
import { PRCommentInput } from "./PRCommentInput";
|
||||||
import { PRInlineComment } from "./PRInlineComment";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -67,6 +70,9 @@ export function PRConversation({
|
|||||||
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
|
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
|
||||||
const [newReviewerUid, setNewReviewerUid] = useState("");
|
const [newReviewerUid, setNewReviewerUid] = useState("");
|
||||||
|
|
||||||
|
// Typing indicator for comment input
|
||||||
|
const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({});
|
||||||
|
|
||||||
// Fetch review comments (general comments only — no path)
|
// Fetch review comments (general comments only — no path)
|
||||||
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
||||||
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
|
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
|
||||||
@ -316,9 +322,7 @@ export function PRConversation({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{review.body && (
|
{review.body && (
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<ContentRenderer content={review.body} className="text-sm text-muted-foreground" />
|
||||||
{review.body}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -339,7 +343,36 @@ export function PRConversation({
|
|||||||
placeholder="Leave a comment..."
|
placeholder="Leave a comment..."
|
||||||
buttonLabel="Comment"
|
buttonLabel="Comment"
|
||||||
onSubmit={(body) => createCommentMutation.mutate(body)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Comment list */}
|
{/* Comment list */}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/client";
|
} from "@/client";
|
||||||
import { PRInlineComment } from "./PRInlineComment";
|
import { PRInlineComment } from "./PRInlineComment";
|
||||||
import { PRCommentInput } from "./PRCommentInput";
|
import { PRCommentInput } from "./PRCommentInput";
|
||||||
|
import { MiniChat } from "@/components/shared/MiniChat";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -18,7 +19,10 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
File,
|
File,
|
||||||
|
MessageSquare,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface PRDiffViewerProps {
|
interface PRDiffViewerProps {
|
||||||
@ -68,6 +72,7 @@ function FileSection({
|
|||||||
repoName,
|
repoName,
|
||||||
prNumber,
|
prNumber,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onOpenChat,
|
||||||
}: {
|
}: {
|
||||||
file: SideBySideFileResponse;
|
file: SideBySideFileResponse;
|
||||||
commentMap: LineCommentMap;
|
commentMap: LineCommentMap;
|
||||||
@ -75,6 +80,7 @@ function FileSection({
|
|||||||
repoName: string;
|
repoName: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onOpenChat: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [addingComment, setAddingComment] = useState<{
|
const [addingComment, setAddingComment] = useState<{
|
||||||
@ -158,6 +164,15 @@ function FileSection({
|
|||||||
-{file.deletions}
|
-{file.deletions}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -267,6 +282,7 @@ export function PRDiffViewer({
|
|||||||
head,
|
head,
|
||||||
}: PRDiffViewerProps) {
|
}: PRDiffViewerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [chatFile, setChatFile] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: diffData, isLoading: diffLoading } = useQuery({
|
const { data: diffData, isLoading: diffLoading } = useQuery({
|
||||||
queryKey: ["pr-diff-side-by-side", namespace, repoName, prNumber, base, head],
|
queryKey: ["pr-diff-side-by-side", namespace, repoName, prNumber, base, head],
|
||||||
@ -345,8 +361,43 @@ export function PRDiffViewer({
|
|||||||
repoName={repoName}
|
repoName={repoName}
|
||||||
prNumber={prNumber}
|
prNumber={prNumber}
|
||||||
onRefresh={handleRefresh}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/client";
|
} from "@/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PRCommentInput } from "./PRCommentInput";
|
import { PRCommentInput } from "./PRCommentInput";
|
||||||
|
import { ContentRenderer } from "@/components/shared/ContentRenderer";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
@ -217,7 +218,7 @@ export function PRInlineComment({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm whitespace-pre-wrap">{comment.body}</div>
|
<ContentRenderer content={comment.body} className="text-sm" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { gitBlobContent, gitCommitLog, gitTreeList } from "@/client";
|
||||||
import { useRepo } from "@/contexts";
|
import { useRepo } from "@/contexts";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { MiniChat } from "@/components/shared/MiniChat";
|
||||||
|
|
||||||
type TreeEntry = {
|
type TreeEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -24,6 +25,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
|||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<{ path: string; name: string; oid: 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
|
// Get the latest commit to find the tree_id for the current branch
|
||||||
const { data: commitsData, isLoading: commitsLoading } = useQuery({
|
const { data: commitsData, isLoading: commitsLoading } = useQuery({
|
||||||
@ -81,13 +89,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
|||||||
} else {
|
} else {
|
||||||
// Open file preview for markdown or show download for others
|
// Open file preview for markdown or show download for others
|
||||||
if (/\.(md|mdx)$/i.test(entry.name)) {
|
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 {
|
} else {
|
||||||
setSelectedFile(entry.name);
|
setSelectedFile(entry.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentPath, updatePath]
|
[currentPath, updatePath, handlePreviewFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGoBack = useCallback(() => {
|
const handleGoBack = useCallback(() => {
|
||||||
@ -225,24 +233,47 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
|||||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
<span className="text-base sm:text-lg truncate">{previewFile.name}</span>
|
<span className="text-base sm:text-lg truncate">{previewFile.name}</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={blobLoading}
|
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
|
Copy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={blobLoading}
|
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 className="h-4 w-4" />
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPreviewFile(null)}
|
onClick={() => handlePreviewFile(null)}
|
||||||
className="p-1.5 border rounded-md hover:bg-muted"
|
className="p-1.5 border rounded-md hover:bg-muted"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@ -253,6 +284,8 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
|||||||
{previewFile.path}
|
{previewFile.path}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{previewTab === 'content' ? (
|
||||||
<div className="flex-1 overflow-auto p-3 sm:p-6">
|
<div className="flex-1 overflow-auto p-3 sm:p-6">
|
||||||
{blobLoading ? (
|
{blobLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@ -274,6 +307,19 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { RoomWithCategory } from '@/contexts/room-context';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCorners,
|
closestCorners,
|
||||||
@ -29,6 +29,8 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
|
|
||||||
interface RoomWithUnread extends RoomWithCategory {
|
interface RoomWithUnread extends RoomWithCategory {
|
||||||
unread_count?: number;
|
unread_count?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscordChannelSidebarProps {
|
interface DiscordChannelSidebarProps {
|
||||||
@ -40,12 +42,15 @@ interface DiscordChannelSidebarProps {
|
|||||||
categories: Array<{ id: string; name: string }>;
|
categories: Array<{ id: string; name: string }>;
|
||||||
onCreateCategory: (name: string) => Promise<void>;
|
onCreateCategory: (name: string) => Promise<void>;
|
||||||
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
|
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
|
||||||
|
onMuteRoom?: (roomId: string, muted: boolean) => void;
|
||||||
|
onArchiveRoom?: (roomId: string, archived: boolean) => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CatName = string;
|
type CatName = string;
|
||||||
|
|
||||||
const DRAG_PREFIX = 'room:';
|
const DRAG_PREFIX = 'room:';
|
||||||
|
const CAT_PREFIX = 'cat:';
|
||||||
|
|
||||||
/* ── Draggable row ─────────────────────────────────────────────── */
|
/* ── Draggable row ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
@ -92,31 +97,66 @@ const RoomButton = memo(function RoomButton({
|
|||||||
room,
|
room,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
onSelectRoom,
|
onSelectRoom,
|
||||||
|
onMute,
|
||||||
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
room: RoomWithCategory;
|
room: RoomWithCategory;
|
||||||
selectedRoomId: string | null;
|
selectedRoomId: string | null;
|
||||||
onSelectRoom: (room: RoomWithCategory) => void;
|
onSelectRoom: (room: RoomWithCategory) => void;
|
||||||
|
onMute?: (roomId: string, muted: boolean) => void;
|
||||||
|
onArchive?: (roomId: string, archived: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isSelected = selectedRoomId === room.id;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectRoom(room)}
|
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" />
|
<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" />
|
<Hash className="discord-channel-hash" />
|
||||||
<span className="discord-channel-name">{room.room_name}</span>
|
<span className="discord-channel-name">{room.room_name}</span>
|
||||||
{!room.public && (
|
{!room.public && !muted && !archived && (
|
||||||
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0 ml-auto" />
|
<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">
|
<span className="discord-mention-badge">
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -124,6 +164,7 @@ const RoomButton = memo(function RoomButton({
|
|||||||
/* ── Category group ────────────────────────────────────────────── */
|
/* ── Category group ────────────────────────────────────────────── */
|
||||||
|
|
||||||
const ChannelGroup = memo(function ChannelGroup({
|
const ChannelGroup = memo(function ChannelGroup({
|
||||||
|
categoryId,
|
||||||
categoryName,
|
categoryName,
|
||||||
rooms,
|
rooms,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
@ -131,7 +172,10 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggle,
|
onToggle,
|
||||||
canReceiveDrops,
|
canReceiveDrops,
|
||||||
|
onMute,
|
||||||
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
|
categoryId: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
rooms: RoomWithCategory[];
|
rooms: RoomWithCategory[];
|
||||||
selectedRoomId: string | null;
|
selectedRoomId: string | null;
|
||||||
@ -139,31 +183,66 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
canReceiveDrops?: true;
|
canReceiveDrops?: true;
|
||||||
|
onMute?: (roomId: string, muted: boolean) => void;
|
||||||
|
onArchive?: (roomId: string, archived: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
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
|
// Make the category header a droppable zone so rooms can be dragged onto it
|
||||||
const { setNodeRef: setHeaderRef, isOver: isOverHeader } = useDroppable({ id: categoryName });
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="discord-channel-category"
|
className="discord-channel-category"
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||||
>
|
>
|
||||||
|
<div ref={setCatRef} style={catStyle} className="flex items-center">
|
||||||
<button
|
<button
|
||||||
ref={setHeaderRef}
|
ref={setHeaderRef}
|
||||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed', isOverHeader && 'ring-1 ring-accent')}
|
{...catAttrs}
|
||||||
|
{...catListeners}
|
||||||
|
className={cn(
|
||||||
|
'discord-channel-category-header w-full flex-1',
|
||||||
|
isCollapsed && 'collapsed',
|
||||||
|
isOverHeader && 'ring-1 ring-accent',
|
||||||
|
)}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="h-3 w-3" />
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3 w-3" />
|
<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>
|
<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>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<ul className="space-y-0.5 pl-2">
|
<ul className="space-y-0.5 pl-2">
|
||||||
@ -174,6 +253,8 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
room={room}
|
room={room}
|
||||||
selectedRoomId={selectedRoomId}
|
selectedRoomId={selectedRoomId}
|
||||||
onSelectRoom={onSelectRoom}
|
onSelectRoom={onSelectRoom}
|
||||||
|
onMute={onMute}
|
||||||
|
onArchive={onArchive}
|
||||||
/>
|
/>
|
||||||
</DraggableRow>
|
</DraggableRow>
|
||||||
))}
|
))}
|
||||||
@ -196,6 +277,8 @@ function ChannelListContent({
|
|||||||
collapsedState,
|
collapsedState,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
onMoveRoom,
|
onMoveRoom,
|
||||||
|
onMute,
|
||||||
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
rooms: RoomWithCategory[];
|
rooms: RoomWithCategory[];
|
||||||
selectedRoomId: string | null;
|
selectedRoomId: string | null;
|
||||||
@ -206,6 +289,8 @@ function ChannelListContent({
|
|||||||
collapsedState: Record<string, boolean>;
|
collapsedState: Record<string, boolean>;
|
||||||
toggleCategory: (name: string) => void;
|
toggleCategory: (name: string) => void;
|
||||||
onMoveRoom: (roomId: string, catId: string | null) => void;
|
onMoveRoom: (roomId: string, catId: string | null) => void;
|
||||||
|
onMute?: (roomId: string, muted: boolean) => void;
|
||||||
|
onArchive?: (roomId: string, archived: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
@ -224,6 +309,14 @@ function ChannelListContent({
|
|||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
const dragId = String(event.active.id);
|
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;
|
if (!dragId.startsWith(DRAG_PREFIX)) return;
|
||||||
|
|
||||||
const draggedRoomId = dragId.slice(DRAG_PREFIX.length);
|
const draggedRoomId = dragId.slice(DRAG_PREFIX.length);
|
||||||
@ -254,17 +347,23 @@ function ChannelListContent({
|
|||||||
{/* Uncategorized channels at top */}
|
{/* Uncategorized channels at top */}
|
||||||
{uncategorizedRooms.length > 0 && (
|
{uncategorizedRooms.length > 0 && (
|
||||||
<ChannelGroup
|
<ChannelGroup
|
||||||
|
categoryId="__uncategorized__"
|
||||||
categoryName="Channels"
|
categoryName="Channels"
|
||||||
rooms={uncategorizedRooms}
|
rooms={uncategorizedRooms}
|
||||||
selectedRoomId={selectedRoomId}
|
selectedRoomId={selectedRoomId}
|
||||||
onSelectRoom={onSelectRoom}
|
onSelectRoom={onSelectRoom}
|
||||||
|
onMute={onMute}
|
||||||
|
onArchive={onArchive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Categorized groups */}
|
{/* Categorized groups */}
|
||||||
{sortedCatNames.map((catName) => (
|
{sortedCatNames.map((catName) => {
|
||||||
|
const cat = categories.find((c) => c.name === catName);
|
||||||
|
return (
|
||||||
<ChannelGroup
|
<ChannelGroup
|
||||||
key={catName}
|
key={catName}
|
||||||
|
categoryId={cat?.id ?? catName}
|
||||||
categoryName={catName}
|
categoryName={catName}
|
||||||
rooms={categorizedRooms.get(catName)!}
|
rooms={categorizedRooms.get(catName)!}
|
||||||
selectedRoomId={selectedRoomId}
|
selectedRoomId={selectedRoomId}
|
||||||
@ -272,8 +371,11 @@ function ChannelListContent({
|
|||||||
isCollapsed={!!collapsedState[catName]}
|
isCollapsed={!!collapsedState[catName]}
|
||||||
onToggle={() => toggleCategory(catName)}
|
onToggle={() => toggleCategory(catName)}
|
||||||
canReceiveDrops
|
canReceiveDrops
|
||||||
|
onMute={onMute}
|
||||||
|
onArchive={onArchive}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -289,6 +391,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
|||||||
categories,
|
categories,
|
||||||
onCreateCategory,
|
onCreateCategory,
|
||||||
onMoveRoomToCategory,
|
onMoveRoomToCategory,
|
||||||
|
onMuteRoom,
|
||||||
|
onArchiveRoom,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: DiscordChannelSidebarProps) {
|
}: DiscordChannelSidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
@ -380,6 +484,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
|||||||
collapsedState={collapsed}
|
collapsedState={collapsed}
|
||||||
toggleCategory={toggleCategory}
|
toggleCategory={toggleCategory}
|
||||||
onMoveRoom={handleMoveRoom}
|
onMoveRoom={handleMoveRoom}
|
||||||
|
onMute={onMuteRoom}
|
||||||
|
onArchive={onArchiveRoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rooms.length === 0 && (
|
{rooms.length === 0 && (
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useUser } from '@/contexts';
|
import { useUser } from '@/contexts';
|
||||||
import { cn } from '@/lib/utils';
|
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 { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSenderUserUid } from './sender';
|
import { getSenderUserUid } from './sender';
|
||||||
@ -62,6 +62,76 @@ export function RoomMessageActions({ message, onEdit, onRevoke, onReply }: RoomM
|
|||||||
Copy
|
Copy
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isOwner && onEdit && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@ -1,12 +1,57 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { pinList, pinRemove, type RoomPinResponse, type RoomMemberResponse } from '@/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { getSenderDisplayName } from './sender';
|
import { getSenderDisplayName } from './sender';
|
||||||
import { formatMessageTime } from './shared/formatters';
|
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 {
|
interface RoomPinPanelProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@ -24,13 +69,21 @@ interface RoomPinPanelProps {
|
|||||||
|
|
||||||
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
|
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [activeCategory, setActiveCategory] = useState<PinCategory>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const { data: pins, isLoading } = useQuery({
|
const { data: pins, isLoading } = useQuery({
|
||||||
queryKey: ['roomPins', roomId],
|
queryKey: ['roomPins', roomId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const resp = await pinList({ path: { room_id: roomId } });
|
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({
|
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 = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||||
const messageMap = new Map(messages.map(m => [m.id, m]));
|
|
||||||
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@ -70,7 +150,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
||||||
Pinned Messages
|
Pinned
|
||||||
</span>
|
</span>
|
||||||
{pins && (
|
{pins && (
|
||||||
<span
|
<span
|
||||||
@ -90,21 +170,72 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-24">
|
<div className="flex items-center justify-center h-24">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
||||||
</div>
|
</div>
|
||||||
) : pins && pins.length === 0 ? (
|
) : filteredPins.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-32 gap-2">
|
<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)' }} />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
|
||||||
{pins?.map((pin) => {
|
{filteredPins.map((pin) => {
|
||||||
const localMsg = messageMap.get(pin.message);
|
const localMsg = messageMap.get(pin.message);
|
||||||
|
const catMeta = pin.category === 'all' ? null : CATEGORY_META[pin.category];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pin.message}
|
key={pin.message}
|
||||||
@ -115,10 +246,13 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Sender row */}
|
{/* 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">
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{member?.user_info?.avatar_url ? (
|
{member?.user_info?.avatar_url ? (
|
||||||
@ -133,12 +267,12 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--room-text)' }}>
|
<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;
|
return member?.user_info?.username ?? pin.pinned_by;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -154,7 +288,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
|
|
||||||
{/* Message preview */}
|
{/* Message preview */}
|
||||||
<p
|
<p
|
||||||
className="text-[13px] line-clamp-2 pl-7"
|
className="text-[13px] line-clamp-2 pl-5"
|
||||||
style={{ color: 'var(--room-text-secondary)' }}
|
style={{ color: 'var(--room-text-secondary)' }}
|
||||||
>
|
>
|
||||||
{localMsg
|
{localMsg
|
||||||
@ -166,9 +300,9 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
|||||||
|
|
||||||
{/* Original sender */}
|
{/* Original sender */}
|
||||||
{localMsg && (
|
{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)' }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders message content — markdown with @[type:id:label] mentions.
|
* Renders room message content — markdown with @[type:id:label] mentions,
|
||||||
* Mentions are protected from markdown parsing by replacing them with
|
* plus code-aware features: smart link previews and code references.
|
||||||
* placeholder tokens before rendering, then restored in custom text components.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import { ContentRenderer } from '@/components/shared/ContentRenderer';
|
||||||
import remarkGfm from 'remark-gfm';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface MessageContentProps {
|
interface MessageContentProps {
|
||||||
@ -16,178 +18,74 @@ interface MessageContentProps {
|
|||||||
onMentionClick?: (type: string, id: string, label: string) => void;
|
onMentionClick?: (type: string, id: string, label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
export const MessageContent = memo(function MessageContent({
|
||||||
|
content,
|
||||||
interface MentionInfo {
|
onMentionClick,
|
||||||
type: string;
|
}: MessageContentProps) {
|
||||||
id: string;
|
// Extract standalone URLs for link preview rendering
|
||||||
label: string;
|
const urlResults = useMemo<UnfurlResult[]>(() => {
|
||||||
}
|
const seen = new Set<string>();
|
||||||
|
const results: UnfurlResult[] = [];
|
||||||
/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
|
for (const { url } of extractUrls(content)) {
|
||||||
function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
|
if (seen.has(url)) continue;
|
||||||
const mentions: MentionInfo[] = [];
|
const result = detectLinkType(url);
|
||||||
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
|
if (result && !result.isExternal) {
|
||||||
const idx = mentions.length;
|
seen.add(url);
|
||||||
mentions.push({ type, id, label });
|
results.push(result);
|
||||||
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));
|
|
||||||
}
|
|
||||||
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>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lastIndex = MENTION_PLACEHOLDER_RE.lastIndex;
|
|
||||||
}
|
}
|
||||||
|
return results;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
// Extract code references (file.rs:42 style)
|
||||||
parts.push(text.slice(lastIndex));
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
return parts;
|
const hasLinkPreviews = urlResults.length > 0;
|
||||||
}
|
const hasCodeRefs = codeRefs.length > 0;
|
||||||
|
|
||||||
export const MessageContent = memo(function MessageContent({ content, onMentionClick }: MessageContentProps) {
|
|
||||||
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('space-y-2', !hasLinkPreviews && !hasCodeRefs && 'space-y-0')}>
|
||||||
className={cn(
|
<ContentRenderer
|
||||||
'text-[15px] text-foreground',
|
content={content}
|
||||||
'max-w-full min-w-0 break-words',
|
onMentionClick={onMentionClick}
|
||||||
'[&_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',
|
{/* Code references */}
|
||||||
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
|
{hasCodeRefs && (
|
||||||
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
|
<div className="space-y-1">
|
||||||
'[&_li]:my-0.5',
|
{codeRefs.map((ref, i) => (
|
||||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
|
<CodeReference
|
||||||
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
|
key={`ref-${i}-${ref.raw}`}
|
||||||
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
|
ref={ref}
|
||||||
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
|
/>
|
||||||
'[&_strong]:font-semibold',
|
))}
|
||||||
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
</div>
|
||||||
'[&_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',
|
{/* Link previews */}
|
||||||
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
|
{hasLinkPreviews && (
|
||||||
'[&_tr]:border-t [&_tr]:even:bg-muted',
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -29,6 +29,8 @@ export interface RepoInfo {
|
|||||||
ssh_clone_url: string;
|
ssh_clone_url: string;
|
||||||
/** HTTPS clone URL */
|
/** HTTPS clone URL */
|
||||||
https_clone_url: string;
|
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);
|
export const RepositoryContext = React.createContext<RepoInfo | null>(null);
|
||||||
@ -101,6 +103,7 @@ export const RepositoryContextProvider = ({
|
|||||||
is_watch: Boolean(watchCountResp),
|
is_watch: Boolean(watchCountResp),
|
||||||
ssh_clone_url: repoItem.ssh_clone_url,
|
ssh_clone_url: repoItem.ssh_clone_url,
|
||||||
https_clone_url: repoItem.https_clone_url,
|
https_clone_url: repoItem.https_clone_url,
|
||||||
|
ai_code_review_enabled: repoItem.ai_code_review_enabled,
|
||||||
};
|
};
|
||||||
}, [repoItem, namespace, starCountResp, watchCountResp]);
|
}, [repoItem, namespace, starCountResp, watchCountResp]);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user