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 issue_triage;
|
||||
pub mod model;
|
||||
pub mod model_capability;
|
||||
pub mod model_parameter_profile;
|
||||
@ -16,6 +17,10 @@ pub fn init_agent_routes(cfg: &mut web::ServiceConfig) {
|
||||
"/code-review/{namespace}/{repo}",
|
||||
web::post().to(code_review::trigger_code_review),
|
||||
)
|
||||
.route(
|
||||
"/{project}/issues/{issue_number}/triage",
|
||||
web::get().to(issue_triage::triage_issue),
|
||||
)
|
||||
.route(
|
||||
"/pr-description/{namespace}/{repo}",
|
||||
web::post().to(pr_summary::generate_pr_description),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
use crate::{ApiResponse, error::ApiError};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::issue::IssueAddLabelsByNamesRequest;
|
||||
use service::AppService;
|
||||
use session::Session;
|
||||
|
||||
@ -85,3 +86,32 @@ pub async fn issue_label_remove(
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/issue/{project}/issues/{number}/labels/bulk",
|
||||
params(
|
||||
("project" = String, Path),
|
||||
("number" = i64, Path),
|
||||
),
|
||||
request_body = IssueAddLabelsByNamesRequest,
|
||||
responses(
|
||||
(status = 200, description = "Add labels to issue by name", body = ApiResponse<Vec<service::issue::IssueLabelResponse>>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
tag = "Issues"
|
||||
)]
|
||||
pub async fn issue_label_add_bulk(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<(String, i64)>,
|
||||
body: web::Json<IssueAddLabelsByNamesRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (project, issue_number) = path.into_inner();
|
||||
let resp = service
|
||||
.issue_label_add_by_names(project, issue_number, body.into_inner(), &session)
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
@ -255,6 +255,10 @@ pub fn init_issue_routes(cfg: &mut web::ServiceConfig) {
|
||||
"/issues/{number}/labels",
|
||||
web::post().to(issue_label::issue_label_add),
|
||||
)
|
||||
.route(
|
||||
"/issues/{number}/labels/bulk",
|
||||
web::post().to(issue_label::issue_label_add_bulk),
|
||||
)
|
||||
.route(
|
||||
"/issues/{number}/labels/{label_id}",
|
||||
web::delete().to(issue_label::issue_label_remove),
|
||||
|
||||
@ -36,6 +36,7 @@ use utoipa::OpenApi;
|
||||
crate::admin::alerts::admin_check_alerts,
|
||||
// Agent (CRUD)
|
||||
crate::agent::code_review::trigger_code_review,
|
||||
crate::agent::issue_triage::triage_issue,
|
||||
crate::agent::pr_summary::generate_pr_description,
|
||||
crate::agent::provider::provider_list,
|
||||
crate::agent::provider::provider_get,
|
||||
@ -247,6 +248,7 @@ use utoipa::OpenApi;
|
||||
crate::issue::issue_summary,
|
||||
crate::issue::issue_label::issue_label_list,
|
||||
crate::issue::issue_label::issue_label_add,
|
||||
crate::issue::issue_label::issue_label_add_bulk,
|
||||
crate::issue::issue_label::issue_label_remove,
|
||||
crate::issue::label::label_list,
|
||||
crate::issue::label::label_create,
|
||||
@ -403,6 +405,7 @@ use utoipa::OpenApi;
|
||||
crate::room::draft_and_history::mention_read_all,
|
||||
// Search
|
||||
crate::search::service::search,
|
||||
crate::search::service::search_messages,
|
||||
crate::room::reaction::message_search,
|
||||
// User
|
||||
crate::user::profile::get_my_profile,
|
||||
@ -482,6 +485,7 @@ use utoipa::OpenApi;
|
||||
service::issue::IssueCommentListResponse,
|
||||
service::issue::IssueLabelResponse,
|
||||
service::issue::IssueAddLabelRequest,
|
||||
service::issue::IssueAddLabelsByNamesRequest,
|
||||
service::issue::LabelResponse,
|
||||
service::issue::CreateLabelRequest,
|
||||
service::issue::ReactionAddRequest,
|
||||
@ -585,6 +589,8 @@ use utoipa::OpenApi;
|
||||
service::agent::code_review::TriggerCodeReviewRequest,
|
||||
service::agent::code_review::TriggerCodeReviewResponse,
|
||||
service::agent::code_review::CommentCreated,
|
||||
service::agent::issue_triage::IssueTriageSuggestion,
|
||||
service::agent::issue_triage::IssueTriageResponse,
|
||||
service::agent::pr_summary::GeneratePrDescriptionRequest,
|
||||
service::agent::pr_summary::GeneratePrDescriptionResponse,
|
||||
service::agent::provider::ProviderResponse,
|
||||
@ -715,6 +721,8 @@ use utoipa::OpenApi;
|
||||
service::search::RepoSearchItem,
|
||||
service::search::IssueSearchItem,
|
||||
service::search::UserSearchItem,
|
||||
service::search::GlobalMessageSearchResponse,
|
||||
service::search::GlobalMessageSearchItem,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
|
||||
@ -10,6 +10,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
|
||||
use room::types::NotificationEvent;
|
||||
use room::connection::RoomConnectionManager;
|
||||
use service::AppService;
|
||||
|
||||
@ -24,7 +25,7 @@ const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Unified push event from any subscribed room.
|
||||
/// Unified push event from any subscribed room or user notification channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WsPushEvent {
|
||||
RoomMessage {
|
||||
@ -44,6 +45,9 @@ pub enum WsPushEvent {
|
||||
room_id: Uuid,
|
||||
event: Arc<TypingEvent>,
|
||||
},
|
||||
Notification {
|
||||
event: Arc<NotificationEvent>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream)
|
||||
@ -159,6 +163,10 @@ pub async fn ws_universal(
|
||||
manager.metrics.ws_connections_active.increment(1.0);
|
||||
manager.metrics.ws_connections_total.increment(1);
|
||||
|
||||
// Subscribe to user-level notification stream immediately on connect
|
||||
let notif_rx = manager.subscribe_user_notification(user_id).await;
|
||||
let mut notif_stream = BroadcastStream::new(notif_rx);
|
||||
|
||||
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?;
|
||||
actix::spawn(async move {
|
||||
let handler = WsRequestHandler::new(Arc::new(service), user_id);
|
||||
@ -195,6 +203,30 @@ pub async fn ws_universal(
|
||||
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
|
||||
break;
|
||||
}
|
||||
notif_result = notif_stream.next() => {
|
||||
match notif_result {
|
||||
Some(Ok(event)) => {
|
||||
let payload = serde_json::json!({
|
||||
"type": "event",
|
||||
"event": "notification_created",
|
||||
"data": {
|
||||
"event_type": event.event_type,
|
||||
"notification": event.notification,
|
||||
"deep_link_url": event.deep_link_url,
|
||||
"timestamp": event.timestamp,
|
||||
},
|
||||
});
|
||||
if session.text(payload.to_string()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
// Notification channel lagged or closed — re-subscribe
|
||||
let rx = manager.subscribe_user_notification(user_id).await;
|
||||
notif_stream = BroadcastStream::new(rx);
|
||||
}
|
||||
}
|
||||
}
|
||||
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
|
||||
match push_event {
|
||||
Some(WsPushEvent::RoomMessage { room_id, event }) => {
|
||||
@ -268,6 +300,9 @@ pub async fn ws_universal(
|
||||
}
|
||||
None => {
|
||||
}
|
||||
Some(WsPushEvent::Notification { .. }) => {
|
||||
// Notification events are handled via the notif_stream branch above
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = msg_stream.recv() => {
|
||||
|
||||
@ -4,4 +4,5 @@ use actix_web::web;
|
||||
|
||||
pub fn init_search_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/search", web::to(service::search));
|
||||
cfg.route("/search/messages", web::to(service::search_messages));
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ use crate::ApiResponse;
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
use service::search::{SearchQuery, SearchResponse};
|
||||
use service::search::{GlobalMessageSearchQuery, GlobalMessageSearchResponse, SearchQuery, SearchResponse};
|
||||
use session::Session;
|
||||
|
||||
#[utoipa::path(
|
||||
@ -29,3 +29,27 @@ pub async fn search(
|
||||
let resp = service.search(&session, query.into_inner()).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/search/messages",
|
||||
params(
|
||||
("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200),
|
||||
("page" = Option<u32>, Query, description = "Page number, default 1"),
|
||||
("per_page" = Option<u32>, Query, description = "Results per page, default 20, max 100"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Message search results across all accessible rooms", body = ApiResponse<GlobalMessageSearchResponse>),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
tag = "Search"
|
||||
)]
|
||||
pub async fn search_messages(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
query: web::Query<GlobalMessageSearchQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let resp = service.global_message_search(&session, query.into_inner()).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
@ -267,7 +267,10 @@ impl RoomService {
|
||||
user_id: Uuid,
|
||||
notification: super::NotificationResponse,
|
||||
) {
|
||||
let event = super::NotificationEvent::new(notification.clone());
|
||||
let deep_link = build_deep_link(¬ification);
|
||||
let deep_link_url = deep_link.clone();
|
||||
let event = super::NotificationEvent::new(notification.clone())
|
||||
.with_deep_link(deep_link);
|
||||
self.room_manager
|
||||
.push_user_notification(user_id, Arc::new(event))
|
||||
.await;
|
||||
@ -278,7 +281,7 @@ impl RoomService {
|
||||
user_id,
|
||||
notification.title.clone(),
|
||||
notification.content.clone(),
|
||||
None, // URL — could be derived from room/project
|
||||
Some(deep_link_url),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -336,3 +339,20 @@ impl RoomService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a frontend deep-link URL from the notification's related IDs.
|
||||
fn build_deep_link(n: &super::NotificationResponse) -> String {
|
||||
if let Some(room_id) = n.related_room_id {
|
||||
return format!("/rooms/{}", room_id);
|
||||
}
|
||||
if let Some(room_id) = n.room {
|
||||
return format!("/rooms/{}", room_id);
|
||||
}
|
||||
if let Some(_msg_id) = n.related_message_id {
|
||||
return "/notify".to_string();
|
||||
}
|
||||
if let Some(project_id) = n.project {
|
||||
return format!("/project/{}", project_id);
|
||||
}
|
||||
"/notify".to_string()
|
||||
}
|
||||
|
||||
@ -369,6 +369,7 @@ pub struct NotificationListResponse {
|
||||
pub struct NotificationEvent {
|
||||
pub event_type: String,
|
||||
pub notification: NotificationResponse,
|
||||
pub deep_link_url: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@ -377,7 +378,13 @@ impl NotificationEvent {
|
||||
Self {
|
||||
event_type: "notification_created".into(),
|
||||
notification,
|
||||
deep_link_url: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_deep_link(mut self, url: String) -> Self {
|
||||
self.deep_link_url = Some(url);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
415
openapi.json
415
openapi.json
@ -1334,6 +1334,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agents/{project}/triage": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Agent"
|
||||
],
|
||||
"operationId": "triage_issue",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"in": "path",
|
||||
"description": "Project name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "issue_number",
|
||||
"in": "query",
|
||||
"description": "Issue number to triage",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Issue triage result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponse_IssueTriageResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "Issue not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/captcha": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -3166,6 +3213,64 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/issue/{project}/issues/{number}/labels/bulk": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Issues"
|
||||
],
|
||||
"operationId": "issue_label_add_bulk",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "number",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IssueAddLabelsByNamesRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Add labels to issue by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponse_Vec_IssueLabelResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/issue/{project}/issues/{number}/labels/{label_id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
@ -19946,6 +20051,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/search/messages": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"operationId": "search_messages",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"description": "Search keyword",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "Page number, default 1",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "per_page",
|
||||
"in": "query",
|
||||
"description": "Results per page, default 20, max 100",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Message search results across all accessible rooms",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponse_GlobalMessageSearchResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/me/access-keys": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -24688,6 +24854,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponse_GlobalMessageSearchResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"query",
|
||||
"messages",
|
||||
"total",
|
||||
"page",
|
||||
"per_page"
|
||||
],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GlobalMessageSearchItem"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponse_InvitationListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -25283,6 +25500,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponse_IssueTriageResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment_posted"
|
||||
],
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/IssueTriageSuggestion"
|
||||
}
|
||||
]
|
||||
},
|
||||
"comment_posted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponse_JoinAnswersListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -34653,6 +34907,12 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"ai_code_review_enabled": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -34667,6 +34927,98 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GlobalMessageSearchItem": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"room_id",
|
||||
"room_name",
|
||||
"sender_type",
|
||||
"content",
|
||||
"content_type",
|
||||
"send_at"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"room_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"room_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sender_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "uuid"
|
||||
},
|
||||
"sender_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"send_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"highlighted_content": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"GlobalMessageSearchResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"query",
|
||||
"messages",
|
||||
"total",
|
||||
"page",
|
||||
"per_page"
|
||||
],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GlobalMessageSearchItem"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"InvitationListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -34811,6 +35163,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueAddLabelsByNamesRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"names"
|
||||
],
|
||||
"properties": {
|
||||
"names": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueAssignUserRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -35280,6 +35646,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueTriageResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment_posted"
|
||||
],
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/IssueTriageSuggestion"
|
||||
}
|
||||
]
|
||||
},
|
||||
"comment_posted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueTriageSuggestion": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"suggested_labels",
|
||||
"priority",
|
||||
"reasoning"
|
||||
],
|
||||
"properties": {
|
||||
"suggested_labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -37453,7 +37862,8 @@
|
||||
"star_count",
|
||||
"watch_count",
|
||||
"ssh_clone_url",
|
||||
"https_clone_url"
|
||||
"https_clone_url",
|
||||
"ai_code_review_enabled"
|
||||
],
|
||||
"properties": {
|
||||
"uid": {
|
||||
@ -37510,6 +37920,9 @@
|
||||
},
|
||||
"https_clone_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"ai_code_review_enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,290 +1,649 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Archive,
|
||||
AtSign,
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { notificationList, notificationMarkRead, notificationMarkAllRead, notificationArchive } from "@/client";
|
||||
import type { NotificationResponse } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {getApiErrorMessage} from '@/lib/api-error';
|
||||
'use client';
|
||||
|
||||
type Filter = "all" | "unread" | "archived";
|
||||
/**
|
||||
* Enhanced notifications page with:
|
||||
* - WebSocket real-time updates via useNotification hook
|
||||
* - Grouping by project/context
|
||||
* - Quick actions (mark read, archive, preview)
|
||||
* - Extended notification type support
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Archive,
|
||||
AtSign,
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Shield,
|
||||
GitPullRequest,
|
||||
CheckCircle,
|
||||
Merge,
|
||||
AlertCircle,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
notificationMarkRead,
|
||||
notificationMarkAllRead,
|
||||
notificationArchive,
|
||||
} from '@/client';
|
||||
import type { NotificationResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getApiErrorMessage } from '@/lib/api-error';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Filter = 'all' | 'unread' | 'archived';
|
||||
type GroupBy = 'none' | 'project' | 'type';
|
||||
|
||||
const NOTIFICATION_TYPE_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; icon: React.ReactNode; color: string }
|
||||
string,
|
||||
{ label: string; icon: React.ReactNode; color: string }
|
||||
> = {
|
||||
mention: { label: "Mention", icon: <AtSign className="h-3.5 w-3.5" />, color: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
|
||||
invitation: { label: "Invitation", icon: <Mail className="h-3.5 w-3.5" />, color: "bg-purple-500/10 text-purple-600 border-purple-500/20" },
|
||||
role_change: { label: "Role Change", icon: <Shield className="h-3.5 w-3.5" />, color: "bg-orange-500/10 text-orange-600 border-orange-500/20" },
|
||||
room_created: { label: "Room Created", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-green-500/10 text-green-600 border-green-500/20" },
|
||||
room_deleted: { label: "Room Deleted", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-red-500/10 text-red-600 border-red-500/20" },
|
||||
system_announcement: { label: "Announcement", icon: <Bell className="h-3.5 w-3.5" />, color: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20" },
|
||||
mention: {
|
||||
label: 'Mention',
|
||||
icon: <AtSign className="h-3.5 w-3.5" />,
|
||||
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||
},
|
||||
invitation: {
|
||||
label: 'Invitation',
|
||||
icon: <Mail className="h-3.5 w-3.5" />,
|
||||
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||
},
|
||||
project_invitation: {
|
||||
label: 'Project Invite',
|
||||
icon: <Mail className="h-3.5 w-3.5" />,
|
||||
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||
},
|
||||
workspace_invitation: {
|
||||
label: 'Workspace Invite',
|
||||
icon: <Mail className="h-3.5 w-3.5" />,
|
||||
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||
},
|
||||
role_change: {
|
||||
label: 'Role Change',
|
||||
icon: <Shield className="h-3.5 w-3.5" />,
|
||||
color: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
|
||||
},
|
||||
room_created: {
|
||||
label: 'Room Created',
|
||||
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||
color: 'bg-green-500/10 text-green-600 border-green-500/20',
|
||||
},
|
||||
room_deleted: {
|
||||
label: 'Room Deleted',
|
||||
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||
color: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||
},
|
||||
system_announcement: {
|
||||
label: 'Announcement',
|
||||
icon: <Bell className="h-3.5 w-3.5" />,
|
||||
color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20',
|
||||
},
|
||||
issue_opened: {
|
||||
label: 'Issue Opened',
|
||||
icon: <AlertCircle className="h-3.5 w-3.5" />,
|
||||
color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
||||
},
|
||||
issue_commented: {
|
||||
label: 'Issue Comment',
|
||||
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||
},
|
||||
issue_closed: {
|
||||
label: 'Issue Closed',
|
||||
icon: <CheckCircle className="h-3.5 w-3.5" />,
|
||||
color: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
|
||||
},
|
||||
pr_review_requested: {
|
||||
label: 'Review Requested',
|
||||
icon: <GitPullRequest className="h-3.5 w-3.5" />,
|
||||
color: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||
},
|
||||
pr_approved: {
|
||||
label: 'PR Approved',
|
||||
icon: <CheckCircle className="h-3.5 w-3.5" />,
|
||||
color: 'bg-green-500/10 text-green-600 border-green-500/20',
|
||||
},
|
||||
pr_merged: {
|
||||
label: 'PR Merged',
|
||||
icon: <Merge className="h-3.5 w-3.5" />,
|
||||
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
n,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
}: {
|
||||
n: NotificationResponse;
|
||||
onMarkRead: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
}) {
|
||||
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
|
||||
label: n.notification_type,
|
||||
icon: <Bell className="h-3.5 w-3.5" />,
|
||||
color: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
function getTypeLabel(type: string): string {
|
||||
return NOTIFICATION_TYPE_CONFIG[type]?.label ?? type;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!n.is_read) onMarkRead(n.id);
|
||||
};
|
||||
interface NotificationItemProps {
|
||||
n: NotificationResponse;
|
||||
onMarkRead: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
onNavigate: (n: NotificationResponse) => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0 ${
|
||||
!n.is_read ? "bg-primary/5" : ""
|
||||
}`}
|
||||
function NotificationItem({ n, onMarkRead, onArchive, onNavigate }: NotificationItemProps) {
|
||||
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
|
||||
label: n.notification_type,
|
||||
icon: <Bell className="h-3.5 w-3.5" />,
|
||||
color: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0',
|
||||
!n.is_read && 'bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!n.is_read) onMarkRead(n.id);
|
||||
onNavigate(n);
|
||||
}}
|
||||
className="text-left flex-1 min-w-0 w-full"
|
||||
>
|
||||
{/* Unread dot */}
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<p className={cn('text-sm truncate', !n.is_read ? 'font-semibold' : 'font-medium')}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.content && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{n.content}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Badge variant="outline" className={cn('text-xs border', config.color)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatTime(n.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center ${config.color}`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!n.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkRead(n.id);
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive(n.id);
|
||||
}}
|
||||
title="Archive"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="text-left flex-1 min-w-0"
|
||||
>
|
||||
<p className={`text-sm truncate ${!n.is_read ? "font-semibold" : "font-medium"}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.content && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{n.content}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Badge variant="outline" className={`text-xs ${config.color} border`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(n.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
interface NotificationGroupProps {
|
||||
title: string;
|
||||
count: number;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!n.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => { e.stopPropagation(); onMarkRead(n.id); }}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onArchive(n.id); }}
|
||||
title="Archive"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function NotificationGroup({ title, count, defaultOpen = true, children }: NotificationGroupProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<Badge variant="secondary" className="text-xs ml-auto">
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotifyPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('none');
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["notifications", filter],
|
||||
queryFn: async () => {
|
||||
const resp = await notificationList({
|
||||
query: {
|
||||
only_unread: filter === "unread",
|
||||
archived: filter === "archived" ? true : undefined,
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markRead,
|
||||
markAllRead,
|
||||
archive,
|
||||
isLive,
|
||||
} = useNotification({
|
||||
showToast: false,
|
||||
});
|
||||
|
||||
const filteredNotifications = notifications.filter((n) => {
|
||||
if (filter === 'unread') return !n.is_read;
|
||||
if (filter === 'archived') return n.is_archived;
|
||||
return !n.is_archived;
|
||||
});
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
await notificationMarkRead({ path: { notification_id: id } });
|
||||
markRead(id);
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed to mark as read'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await notificationArchive({ path: { notification_id: id } });
|
||||
archive(id);
|
||||
toast.success('Notification archived');
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed to archive'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await notificationMarkAllRead();
|
||||
markAllRead();
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed to mark all as read'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (n: NotificationResponse) => {
|
||||
// Determine navigation target from notification metadata
|
||||
if (n.related_room_id && n.project) {
|
||||
navigate(`/project/${n.project}/room`);
|
||||
} else if (n.notification_type.includes('invitation')) {
|
||||
navigate('/invitations');
|
||||
} else if (n.notification_type.startsWith('issue')) {
|
||||
// Navigate to project/issue if project is known
|
||||
if (n.project) {
|
||||
navigate(`/project/${n.project}/issues`);
|
||||
}
|
||||
} else if (n.notification_type.startsWith('pr')) {
|
||||
if (n.project) {
|
||||
navigate(`/project/${n.project}/pull-requests`);
|
||||
}
|
||||
} else {
|
||||
navigate('/notify');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: async (notificationId: string) => {
|
||||
await notificationMarkRead({ path: { notification_id: notificationId } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to mark as read"));
|
||||
},
|
||||
});
|
||||
const handleBatchArchive = async () => {
|
||||
for (const id of selectedIds) {
|
||||
try {
|
||||
await notificationArchive({ path: { notification_id: id } });
|
||||
archive(id);
|
||||
} catch {
|
||||
// Continue with others
|
||||
}
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchMode(false);
|
||||
toast.success(`${selectedIds.size} notifications archived`);
|
||||
};
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await notificationMarkAllRead();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("All notifications marked as read");
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to mark all as read"));
|
||||
},
|
||||
});
|
||||
const handleBatchMarkRead = async () => {
|
||||
for (const id of selectedIds) {
|
||||
try {
|
||||
await notificationMarkRead({ path: { notification_id: id } });
|
||||
markRead(id);
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setSelectedIds(new Set());
|
||||
setBatchMode(false);
|
||||
};
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (notificationId: string) => {
|
||||
await notificationArchive({ path: { notification_id: notificationId } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Notification archived");
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to archive"));
|
||||
},
|
||||
});
|
||||
const handleMarkReadForGroup = async (ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
await notificationMarkRead({ path: { notification_id: id } });
|
||||
markRead(id);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
};
|
||||
|
||||
const notifications: NotificationResponse[] = data?.notifications ?? [];
|
||||
const total: number = data?.total ?? 0;
|
||||
const unreadCount: number = data?.unread_count ?? 0;
|
||||
// Group notifications
|
||||
const groups = (() => {
|
||||
if (groupBy === 'project') {
|
||||
const byProject = new Map<string, NotificationResponse[]>();
|
||||
for (const n of filteredNotifications) {
|
||||
const key = n.project ?? 'Unknown Project';
|
||||
if (!byProject.has(key)) byProject.set(key, []);
|
||||
byProject.get(key)!.push(n);
|
||||
}
|
||||
return [...byProject.entries()].map(([title, items]) => ({
|
||||
title,
|
||||
items,
|
||||
}));
|
||||
}
|
||||
if (groupBy === 'type') {
|
||||
const byType = new Map<string, NotificationResponse[]>();
|
||||
for (const n of filteredNotifications) {
|
||||
const key = getTypeLabel(n.notification_type);
|
||||
if (!byType.has(key)) byType.set(key, []);
|
||||
byType.get(key)!.push(n);
|
||||
}
|
||||
return [...byType.entries()].map(([title, items]) => ({
|
||||
title,
|
||||
items,
|
||||
}));
|
||||
}
|
||||
return [{ title: 'All', items: filteredNotifications }];
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{unreadCount > 0
|
||||
? `${unreadCount} unread · ${total} total`
|
||||
: `${total} notification${total !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
{unreadCount > 0 && filter !== "archived" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
const total = filteredNotifications.length;
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
{(["all", "unread", "archived"] as Filter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
filter === f
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "unread" ? "Unread" : "Archived"}
|
||||
{f === "unread" && unreadCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<BellOff className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="font-medium">
|
||||
{filter === "unread" ? "No unread notifications" : filter === "archived" ? "No archived notifications" : "No notifications yet"}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{filter === "unread"
|
||||
? "You're all caught up!"
|
||||
: filter === "archived"
|
||||
? "Archived notifications will appear here."
|
||||
: "You'll see notifications here when something happens."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
n={n}
|
||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
||||
onArchive={(id) => archiveMutation.mutate(id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
{isLive && (
|
||||
<span className="flex items-center gap-1 text-xs font-normal text-green-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{unreadCount > 0
|
||||
? `${unreadCount} unread · ${total} total`
|
||||
: `${total} notification${total !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Group by selector */}
|
||||
<div className="flex items-center gap-1 border rounded-md px-1">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground mx-1" />
|
||||
<select
|
||||
className="bg-transparent text-xs border-0 outline-none py-1 pr-1 cursor-pointer"
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as GroupBy)}
|
||||
>
|
||||
<option value="none">No grouping</option>
|
||||
<option value="project">Group by project</option>
|
||||
<option value="type">Group by type</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set());
|
||||
setBatchMode(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleBatchMarkRead}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1" />
|
||||
Mark read ({selectedIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleBatchArchive}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5 mr-1" />
|
||||
Archive ({selectedIds.size})
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{unreadCount > 0 && filter !== 'archived' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markAllRead === undefined}
|
||||
>
|
||||
{false ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => navigate('/settings/preferences')}
|
||||
title="Notification settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
{(['all', 'unread', 'archived'] as Filter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
filter === f
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f === 'all' ? 'All' : f === 'unread' ? 'Unread' : 'Archived'}
|
||||
{f === 'unread' && unreadCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Batch mode toggle */}
|
||||
<div className="ml-auto flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBatchMode((m) => !m);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-medium transition-colors',
|
||||
batchMode
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{batchMode ? '✓ Select mode active' : 'Select multiple'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-3">
|
||||
{total === 0 ? (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<BellOff className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="font-medium">
|
||||
{filter === 'unread'
|
||||
? 'No unread notifications'
|
||||
: filter === 'archived'
|
||||
? 'No archived notifications'
|
||||
: 'No notifications yet'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{filter === 'unread'
|
||||
? "You're all caught up!"
|
||||
: filter === 'archived'
|
||||
? 'Archived notifications will appear here.'
|
||||
: "You'll see notifications here when something happens."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : groupBy !== 'none' ? (
|
||||
groups.map(({ title, items }) => (
|
||||
<NotificationGroup key={title} title={title} count={items.length}>
|
||||
{items.map((n) => (
|
||||
<div key={n.id} className="flex items-start">
|
||||
{batchMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ml-4 mt-4 mr-0 flex-shrink-0 accent-primary"
|
||||
checked={selectedIds.has(n.id)}
|
||||
onChange={() => toggleSelect(n.id)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<NotificationItem
|
||||
n={n}
|
||||
onMarkRead={handleMarkRead}
|
||||
onArchive={handleArchive}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-4 pb-2 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleMarkReadForGroup(items.map((i) => i.id))}
|
||||
>
|
||||
<CheckCheck className="h-3 w-3 mr-1" />
|
||||
Mark all read in group
|
||||
</Button>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
))
|
||||
) : (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
{filteredNotifications.map((n) => (
|
||||
<div key={n.id} className="flex items-start">
|
||||
{batchMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ml-4 mt-4 mr-0 flex-shrink-0 accent-primary"
|
||||
checked={selectedIds.has(n.id)}
|
||||
onChange={() => toggleSelect(n.id)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<NotificationItem
|
||||
n={n}
|
||||
onMarkRead={handleMarkRead}
|
||||
onArchive={handleArchive}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Edit,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@ -19,15 +22,20 @@ import {
|
||||
issueCommentList,
|
||||
issueCommentUpdate,
|
||||
issueGet,
|
||||
issueLabelAddBulk,
|
||||
issueReopen,
|
||||
issueRepoList,
|
||||
triageIssue,
|
||||
} from '@/client';
|
||||
import type { IssueCommentResponse } from '@/client/types.gen';
|
||||
import { useProject } from '@/contexts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {getApiErrorMessage} from '@/lib/api-error';
|
||||
import { ContentRenderer } from '@/components/shared/ContentRenderer';
|
||||
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
|
||||
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
const styles =
|
||||
@ -62,6 +70,9 @@ export function IssueDetail() {
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingContent, setEditingContent] = useState('');
|
||||
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { typingUsers } = useTypingIndicator({});
|
||||
|
||||
const no = Number(issueNumber || 0);
|
||||
|
||||
@ -112,6 +123,37 @@ export function IssueDetail() {
|
||||
|
||||
const linkedRepos = (linkedReposRaw as { repos?: { repo: string; repo_name: string }[] })?.repos ?? [];
|
||||
|
||||
const [triageDismissed, setTriageDismissed] = useState(false);
|
||||
|
||||
const { data: triageData } = useQuery({
|
||||
queryKey: ['issueTriage', project?.name, no],
|
||||
queryFn: async () => {
|
||||
const resp = await triageIssue({
|
||||
path: { project: project!.name },
|
||||
query: { issue_number: no },
|
||||
});
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
enabled: !!project?.name && !!no && !triageDismissed,
|
||||
});
|
||||
|
||||
const applyLabelsMutation = useMutation({
|
||||
mutationFn: async (names: string[]) => {
|
||||
await issueLabelAddBulk({
|
||||
path: { project: project!.name, number: no },
|
||||
body: { names },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Labels applied');
|
||||
setTriageDismissed(true);
|
||||
refetchComments();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, 'Failed to apply labels'));
|
||||
},
|
||||
});
|
||||
|
||||
const timeline: TimelineItem[] = [];
|
||||
|
||||
if (issue) {
|
||||
@ -273,9 +315,72 @@ export function IssueDetail() {
|
||||
{/* Description */}
|
||||
{issue.body && (
|
||||
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground/90 leading-relaxed">
|
||||
{issue.body}
|
||||
</p>
|
||||
<ContentRenderer content={issue.body} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Triage Suggestions */}
|
||||
{triageData?.suggestions && !triageDismissed && (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
AI Triage
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
triageData.suggestions.priority === 'high'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-400'
|
||||
: triageData.suggestions.priority === 'medium'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-400'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-400'
|
||||
}`}>
|
||||
{triageData.suggestions.priority} priority
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300 mb-3">
|
||||
{triageData.suggestions.reasoning}
|
||||
</p>
|
||||
{triageData.suggestions.suggested_labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{triageData.suggestions.suggested_labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 px-3 text-xs bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={() =>
|
||||
applyLabelsMutation.mutate(
|
||||
triageData.suggestions!.suggested_labels
|
||||
)
|
||||
}
|
||||
disabled={applyLabelsMutation.isPending || triageData.suggestions.suggested_labels.length === 0}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
{applyLabelsMutation.isPending ? 'Applying…' : 'Accept'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-amber-700 dark:text-amber-400"
|
||||
onClick={() => setTriageDismissed(true)}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -395,9 +500,7 @@ export function IssueDetail() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{comment.body}
|
||||
</p>
|
||||
<ContentRenderer content={comment.body} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -415,7 +518,12 @@ export function IssueDetail() {
|
||||
<div className="p-4">
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setNewComment(v);
|
||||
if (v && typingStopTimer.current) { clearTimeout(typingStopTimer.current); typingStopTimer.current = null; }
|
||||
if (v && !typingStopTimer.current) { typingStopTimer.current = setTimeout(() => { typingStopTimer.current = null; }, 1500); }
|
||||
}}
|
||||
placeholder="Leave a comment…"
|
||||
rows={4}
|
||||
className="resize-none mb-3 text-sm"
|
||||
@ -423,10 +531,8 @@ export function IssueDetail() {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!newComment.trim()) {
|
||||
toast.error('Comment cannot be empty');
|
||||
return;
|
||||
}
|
||||
if (!newComment.trim()) { toast.error('Comment cannot be empty'); return; }
|
||||
if (typingStopTimer.current) { clearTimeout(typingStopTimer.current); typingStopTimer.current = null; }
|
||||
createCommentMutation.mutate(newComment.trim());
|
||||
}}
|
||||
disabled={createCommentMutation.isPending || !newComment.trim()}
|
||||
@ -434,6 +540,33 @@ export function IssueDetail() {
|
||||
{createCommentMutation.isPending ? 'Posting…' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
{typingUsers.length > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<div className="flex -space-x-1.5">
|
||||
{typingUsers.slice(0, 3).map((u) => (
|
||||
<Avatar key={u.userId} className="h-5 w-5 border border-background">
|
||||
{u.avatarUrl ? (
|
||||
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
|
||||
) : (
|
||||
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typingUsers.length === 1
|
||||
? `${typingUsers[0].username} is typing…`
|
||||
: typingUsers.length === 2
|
||||
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
|
||||
: `${typingUsers.length} people are typing…`}
|
||||
</span>
|
||||
<span className="flex gap-0.5 ml-1">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Edit2,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@ -129,6 +131,23 @@ export function RepoSettingsGeneral() {
|
||||
},
|
||||
});
|
||||
|
||||
// AI Code Review
|
||||
const aiReviewMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await gitUpdateRepo({
|
||||
path: { namespace: ns, repo: rn },
|
||||
body: { ai_code_review_enabled: enabled },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("AI code review setting updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to update AI code review setting"));
|
||||
},
|
||||
});
|
||||
|
||||
const branches: Array<{ name: string }> = branchesData ?? [];
|
||||
|
||||
if (!repo) return null;
|
||||
@ -280,6 +299,34 @@ export function RepoSettingsGeneral() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Settings */}
|
||||
<div className="border rounded-lg bg-card">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI Collaborator
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium">Automatic Code Review</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
When enabled, AI will automatically review pull requests when they are opened and post structured comments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{aiReviewMutation.isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
<Switch
|
||||
checked={repo.ai_code_review_enabled ?? false}
|
||||
onCheckedChange={(checked) => aiReviewMutation.mutate(checked)}
|
||||
disabled={aiReviewMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Hexagon,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Users,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { client } from '@/client/client.gen';
|
||||
import { search } from '@/client/sdk.gen';
|
||||
import { messageSearch, search, searchMessages } from '@/client/sdk.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -21,12 +23,16 @@ import type {
|
||||
RepoSearchItem,
|
||||
IssueSearchItem,
|
||||
UserSearchItem,
|
||||
MessageSearchResponse,
|
||||
RoomMessageResponse,
|
||||
SearchResponse,
|
||||
GlobalMessageSearchResponse,
|
||||
GlobalMessageSearchItem,
|
||||
} from '@/client/types.gen';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_TYPES = ['projects', 'repos', 'issues', 'users'] as const;
|
||||
const ALL_TYPES = ['projects', 'repos', 'issues', 'users', 'messages'] as const;
|
||||
type SearchType = typeof ALL_TYPES[number];
|
||||
|
||||
const TYPE_LABELS: Record<SearchType, string> = {
|
||||
@ -34,6 +40,7 @@ const TYPE_LABELS: Record<SearchType, string> = {
|
||||
repos: 'Repositories',
|
||||
issues: 'Issues',
|
||||
users: 'Users',
|
||||
messages: 'Messages',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>> = {
|
||||
@ -41,6 +48,7 @@ const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>
|
||||
repos: FolderGit2,
|
||||
issues: GitPullRequest,
|
||||
users: Users,
|
||||
messages: MessageSquare,
|
||||
};
|
||||
|
||||
function getTotal(results: SearchResponse): number {
|
||||
@ -150,6 +158,50 @@ function UserItem({ item }: { item: UserSearchItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
type MessageSearchItem = RoomMessageResponse | GlobalMessageSearchItem;
|
||||
|
||||
function isGlobalMessage(item: MessageSearchItem): item is GlobalMessageSearchItem {
|
||||
return 'room_id' in item && 'room_name' in item;
|
||||
}
|
||||
|
||||
function MessageItem({ item }: { item: MessageSearchItem }) {
|
||||
const isGlobal = isGlobalMessage(item);
|
||||
const roomLabel = isGlobal ? item.room_name : item.room;
|
||||
const roomHref = isGlobal
|
||||
? `/rooms/${item.room_id}`
|
||||
: `/project/${item.room.split(':')[0]}/room/${item.room.split(':')[1] ?? item.room}`;
|
||||
const displayContent = isGlobal ? (item.highlighted_content ?? item.content) : item.content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={roomHref}
|
||||
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||
{(item.display_name ?? item.sender_id ?? '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm hover:underline">
|
||||
{item.display_name ?? item.sender_id ?? 'Unknown'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0 truncate max-w-[120px]">
|
||||
{roomLabel}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{formatDistanceToNow(parseISO(item.send_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs mt-0.5 line-clamp-2 whitespace-pre-wrap"
|
||||
style={{ color: 'var(--muted-foreground)' }}
|
||||
dangerouslySetInnerHTML={{ __html: displayContent }}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultSection<T>({
|
||||
type,
|
||||
result,
|
||||
@ -192,6 +244,7 @@ function ResultSection<T>({
|
||||
|
||||
export default function SearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [roomIdInput, setRoomIdInput] = useState('');
|
||||
|
||||
const q = searchParams.get('q') ?? '';
|
||||
const typeParam = searchParams.get('type') ?? '';
|
||||
@ -203,6 +256,9 @@ export default function SearchPage() {
|
||||
? (typeParam.split(',').filter((t): t is SearchType => ALL_TYPES.includes(t as SearchType)))
|
||||
: [...ALL_TYPES];
|
||||
|
||||
const showMessages = activeTypes.includes('messages');
|
||||
const useRoomScoped = roomIdInput.trim().length > 0;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['search', q, typeParam, page],
|
||||
queryFn: async () => {
|
||||
@ -220,6 +276,34 @@ export default function SearchPage() {
|
||||
enabled: q.trim().length > 0,
|
||||
});
|
||||
|
||||
// Global message search across all accessible rooms
|
||||
const { data: globalMessagesData, isLoading: globalMessagesLoading } = useQuery({
|
||||
queryKey: ['search-messages-global', q],
|
||||
queryFn: async () => {
|
||||
const resp = await searchMessages({
|
||||
query: { q, page: 1, per_page: 20 },
|
||||
});
|
||||
return resp.data?.data as GlobalMessageSearchResponse;
|
||||
},
|
||||
enabled: q.trim().length > 0 && showMessages && !useRoomScoped,
|
||||
});
|
||||
|
||||
// Room-scoped message search (when room ID is explicitly provided)
|
||||
const { data: roomMessagesData, isLoading: roomMessagesLoading } = useQuery({
|
||||
queryKey: ['search-messages-room', q, roomIdInput],
|
||||
queryFn: async () => {
|
||||
const resp = await messageSearch({
|
||||
path: { room_id: roomIdInput.trim() },
|
||||
query: { q, limit: 20 },
|
||||
});
|
||||
return resp.data?.data as MessageSearchResponse;
|
||||
},
|
||||
enabled: q.trim().length > 0 && showMessages && useRoomScoped,
|
||||
});
|
||||
|
||||
const messagesData = useRoomScoped ? roomMessagesData : globalMessagesData;
|
||||
const messagesLoading = useRoomScoped ? roomMessagesLoading : globalMessagesLoading;
|
||||
|
||||
const results = data ?? null;
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
@ -280,12 +364,24 @@ export default function SearchPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Room ID input for messages search */}
|
||||
{showMessages && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
placeholder="Room ID to search messages in (e.g. workspace:general)..."
|
||||
value={roomIdInput}
|
||||
onChange={(e) => setRoomIdInput(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
{isLoading && (
|
||||
{isLoading && !showMessages && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@ -310,8 +406,10 @@ export default function SearchPage() {
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getTotal(results) > 0
|
||||
{results && getTotal(results) > 0
|
||||
? `${getTotal(results)} results for "${q}"`
|
||||
: showMessages && messagesData
|
||||
? `${messagesData.total} message${messagesData.total === 1 ? '' : 's'} for "${q}"${useRoomScoped ? ` in room ${roomIdInput}` : ' across all accessible rooms'}`
|
||||
: `No results for "${q}"`}
|
||||
</p>
|
||||
</div>
|
||||
@ -345,6 +443,59 @@ export default function SearchPage() {
|
||||
renderer={(item) => <UserItem item={item} />}
|
||||
/>
|
||||
)}
|
||||
{activeTypes.includes('messages') && (
|
||||
<>
|
||||
{messagesLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!messagesLoading && messagesData && messagesData.messages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Messages
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{messagesData.total}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
{useRoomScoped ? (
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
in room <span className="font-mono font-medium">{roomIdInput}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
across all accessible rooms
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{messagesData.messages.map((msg) => (
|
||||
<MessageItem key={msg.id} item={msg} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!messagesLoading && messagesData && messagesData.messages.length === 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Messages
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{useRoomScoped
|
||||
? `No messages found in room "${roomIdInput}" matching "${q}"`
|
||||
: `No messages found matching "${q}" across accessible rooms`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getTotal(results) === 0 && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing } from 'lucide-react';
|
||||
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing, AlertCircle, GitPullRequest, MessageSquare, MessageCircle } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -36,6 +36,12 @@ export function SettingsPreferences() {
|
||||
const [securityEnabled, setSecurityEnabled] = useState(true);
|
||||
const [productEnabled, setProductEnabled] = useState(true);
|
||||
|
||||
// Development activity categories (stored locally until backend supports them)
|
||||
const [issueActivityEnabled, setIssueActivityEnabled] = useState(true);
|
||||
const [prActivityEnabled, setPrActivityEnabled] = useState(true);
|
||||
const [mentionActivityEnabled, setMentionActivityEnabled] = useState(true);
|
||||
const [chatActivityEnabled, setChatActivityEnabled] = useState(true);
|
||||
|
||||
// Fetch notification preferences
|
||||
const { data: preferences, isLoading } = useQuery({
|
||||
queryKey: ['notificationPreferences'],
|
||||
@ -322,6 +328,67 @@ export function SettingsPreferences() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Development Activity — Issue / PR / Mention / Chat */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Development Activity</CardTitle>
|
||||
<CardDescription>Control notifications for code review and collaboration activity.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="issue-activity" className="cursor-pointer">
|
||||
Issues
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Opened, closed, assigned, or commented on</p>
|
||||
</div>
|
||||
<Switch id="issue-activity" checked={issueActivityEnabled} onCheckedChange={setIssueActivityEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="pr-activity" className="cursor-pointer">
|
||||
Pull Requests
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Review requested, approved, merged, or commented on</p>
|
||||
</div>
|
||||
<Switch id="pr-activity" checked={prActivityEnabled} onCheckedChange={setPrActivityEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="mention-activity" className="cursor-pointer">
|
||||
@Mentions
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">When someone @mentions you in a message, issue, or PR</p>
|
||||
</div>
|
||||
<Switch id="mention-activity" checked={mentionActivityEnabled} onCheckedChange={setMentionActivityEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="chat-activity" className="cursor-pointer">
|
||||
Room Messages
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">New messages in channels you follow</p>
|
||||
</div>
|
||||
<Switch id="chat-activity" checked={chatActivityEnabled} onCheckedChange={setChatActivityEnabled} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@ -336,6 +403,10 @@ export function SettingsPreferences() {
|
||||
setMarketingEnabled(preferences.marketing_enabled ?? false);
|
||||
setSecurityEnabled(preferences.security_enabled ?? true);
|
||||
setProductEnabled(preferences.product_enabled ?? true);
|
||||
setIssueActivityEnabled(true);
|
||||
setPrActivityEnabled(true);
|
||||
setMentionActivityEnabled(true);
|
||||
setChatActivityEnabled(true);
|
||||
}
|
||||
}}
|
||||
disabled={updatePreferencesMutation.isPending}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -826,6 +826,18 @@ export type ApiResponseGitReadmeResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponseGlobalMessageSearchResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: {
|
||||
query: string;
|
||||
messages: Array<GlobalMessageSearchItem>;
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponseInvitationListResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
@ -975,6 +987,15 @@ export type ApiResponseIssueSummaryResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponseIssueTriageResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: {
|
||||
suggestions?: null | IssueTriageSuggestion;
|
||||
comment_posted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponseJoinAnswersListResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
@ -3374,6 +3395,7 @@ export type GitReadmeResponse = {
|
||||
|
||||
export type GitUpdateRepoRequest = {
|
||||
default_branch?: string | null;
|
||||
ai_code_review_enabled?: boolean | null;
|
||||
};
|
||||
|
||||
export type GitWatchRequest = {
|
||||
@ -3381,6 +3403,27 @@ export type GitWatchRequest = {
|
||||
notify_email?: boolean;
|
||||
};
|
||||
|
||||
export type GlobalMessageSearchItem = {
|
||||
id: string;
|
||||
room_id: string;
|
||||
room_name: string;
|
||||
sender_id?: string | null;
|
||||
sender_type: string;
|
||||
display_name?: string | null;
|
||||
content: string;
|
||||
content_type: string;
|
||||
send_at: string;
|
||||
highlighted_content?: string | null;
|
||||
};
|
||||
|
||||
export type GlobalMessageSearchResponse = {
|
||||
query: string;
|
||||
messages: Array<GlobalMessageSearchItem>;
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
export type InvitationListResponse = {
|
||||
invitations: Array<InvitationResponse>;
|
||||
total: number;
|
||||
@ -3419,6 +3462,10 @@ export type IssueAddLabelRequest = {
|
||||
label_id: number;
|
||||
};
|
||||
|
||||
export type IssueAddLabelsByNamesRequest = {
|
||||
names: Array<string>;
|
||||
};
|
||||
|
||||
export type IssueAssignUserRequest = {
|
||||
user_id: string;
|
||||
};
|
||||
@ -3540,6 +3587,17 @@ export type IssueSummaryResponse = {
|
||||
closed: number;
|
||||
};
|
||||
|
||||
export type IssueTriageResponse = {
|
||||
suggestions?: null | IssueTriageSuggestion;
|
||||
comment_posted: boolean;
|
||||
};
|
||||
|
||||
export type IssueTriageSuggestion = {
|
||||
suggested_labels: Array<string>;
|
||||
priority: string;
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
export type IssueUpdateRequest = {
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
@ -4141,6 +4199,7 @@ export type ProjectRepositoryItem = {
|
||||
last_commit_at?: string | null;
|
||||
ssh_clone_url: string;
|
||||
https_clone_url: string;
|
||||
ai_code_review_enabled: boolean;
|
||||
};
|
||||
|
||||
export type ProjectRepositoryPagination = {
|
||||
@ -6155,6 +6214,43 @@ export type ModelPricingListResponses = {
|
||||
|
||||
export type ModelPricingListResponse = ModelPricingListResponses[keyof ModelPricingListResponses];
|
||||
|
||||
export type TriageIssueData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Project name
|
||||
*/
|
||||
project: string;
|
||||
};
|
||||
query: {
|
||||
/**
|
||||
* Issue number to triage
|
||||
*/
|
||||
issue_number: number;
|
||||
};
|
||||
url: '/api/agents/{project}/triage';
|
||||
};
|
||||
|
||||
export type TriageIssueErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Issue not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type TriageIssueResponses = {
|
||||
/**
|
||||
* Issue triage result
|
||||
*/
|
||||
200: ApiResponseIssueTriageResponse;
|
||||
};
|
||||
|
||||
export type TriageIssueResponse = TriageIssueResponses[keyof TriageIssueResponses];
|
||||
|
||||
export type ApiAuthCaptchaData = {
|
||||
body: CaptchaQuery;
|
||||
path?: never;
|
||||
@ -7287,6 +7383,40 @@ export type IssueLabelAddResponses = {
|
||||
|
||||
export type IssueLabelAddResponse = IssueLabelAddResponses[keyof IssueLabelAddResponses];
|
||||
|
||||
export type IssueLabelAddBulkData = {
|
||||
body: IssueAddLabelsByNamesRequest;
|
||||
path: {
|
||||
project: string;
|
||||
number: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/issue/{project}/issues/{number}/labels/bulk';
|
||||
};
|
||||
|
||||
export type IssueLabelAddBulkErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown;
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type IssueLabelAddBulkResponses = {
|
||||
/**
|
||||
* Add labels to issue by name
|
||||
*/
|
||||
200: ApiResponseVecIssueLabelResponse;
|
||||
};
|
||||
|
||||
export type IssueLabelAddBulkResponse = IssueLabelAddBulkResponses[keyof IssueLabelAddBulkResponses];
|
||||
|
||||
export type IssueLabelRemoveData = {
|
||||
body?: never;
|
||||
path: {
|
||||
@ -17619,6 +17749,46 @@ export type SearchResponses = {
|
||||
|
||||
export type SearchResponse2 = SearchResponses[keyof SearchResponses];
|
||||
|
||||
export type SearchMessagesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query: {
|
||||
/**
|
||||
* Search keyword
|
||||
*/
|
||||
q: string;
|
||||
/**
|
||||
* Page number, default 1
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Results per page, default 20, max 100
|
||||
*/
|
||||
per_page?: number;
|
||||
};
|
||||
url: '/api/search/messages';
|
||||
};
|
||||
|
||||
export type SearchMessagesErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type SearchMessagesResponses = {
|
||||
/**
|
||||
* Message search results across all accessible rooms
|
||||
*/
|
||||
200: ApiResponseGlobalMessageSearchResponse;
|
||||
};
|
||||
|
||||
export type SearchMessagesResponse = SearchMessagesResponses[keyof SearchMessagesResponses];
|
||||
|
||||
export type ListAccessKeysData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Send, Eye, Edit2 } from "lucide-react";
|
||||
@ -22,6 +22,10 @@ interface PRCommentInputProps {
|
||||
autoFocus?: boolean;
|
||||
/** Minimum height of the textarea */
|
||||
minRows?: number;
|
||||
/** Called when user starts typing (for typing indicator) */
|
||||
onTypingStart?: () => void;
|
||||
/** Called when user stops typing (for typing indicator) */
|
||||
onTypingStop?: () => void;
|
||||
}
|
||||
|
||||
export function PRCommentInput({
|
||||
@ -34,14 +38,30 @@ export function PRCommentInput({
|
||||
onCancel,
|
||||
autoFocus = false,
|
||||
minRows = 3,
|
||||
onTypingStart,
|
||||
onTypingStop,
|
||||
}: PRCommentInputProps) {
|
||||
const [body, setBody] = useState(initialValue);
|
||||
const [isPreview, setIsPreview] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const stopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleTypingChange = useCallback((value: string) => {
|
||||
setBody(value);
|
||||
if (value && onTypingStart) {
|
||||
onTypingStart();
|
||||
}
|
||||
if (stopTimerRef.current) clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = setTimeout(() => {
|
||||
if (onTypingStop) onTypingStop();
|
||||
}, 1500);
|
||||
}, [onTypingStart, onTypingStop]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!body.trim()) return;
|
||||
setIsSubmitting(true);
|
||||
if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; }
|
||||
if (onTypingStop) onTypingStop();
|
||||
try {
|
||||
await onSubmit(body.trim());
|
||||
setBody("");
|
||||
@ -91,7 +111,7 @@ export function PRCommentInput({
|
||||
) : (
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onChange={(e) => handleTypingChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={minRows}
|
||||
autoFocus={autoFocus}
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
} from "@/client";
|
||||
import { PRCommentInput } from "./PRCommentInput";
|
||||
import { PRInlineComment } from "./PRInlineComment";
|
||||
import { ContentRenderer } from "@/components/shared/ContentRenderer";
|
||||
import { useTypingIndicator } from "@/hooks/useTypingIndicator";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -67,6 +70,9 @@ export function PRConversation({
|
||||
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
|
||||
const [newReviewerUid, setNewReviewerUid] = useState("");
|
||||
|
||||
// Typing indicator for comment input
|
||||
const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({});
|
||||
|
||||
// Fetch review comments (general comments only — no path)
|
||||
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
||||
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
|
||||
@ -316,9 +322,7 @@ export function PRConversation({
|
||||
</span>
|
||||
</div>
|
||||
{review.body && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{review.body}
|
||||
</p>
|
||||
<ContentRenderer content={review.body} className="text-sm text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@ -339,7 +343,36 @@ export function PRConversation({
|
||||
placeholder="Leave a comment..."
|
||||
buttonLabel="Comment"
|
||||
onSubmit={(body) => createCommentMutation.mutate(body)}
|
||||
onTypingStart={sendTypingStart}
|
||||
onTypingStop={sendTypingStop}
|
||||
/>
|
||||
{typingUsers.length > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="flex -space-x-1.5">
|
||||
{typingUsers.slice(0, 3).map((u) => (
|
||||
<Avatar key={u.userId} className="h-5 w-5 border border-background">
|
||||
{u.avatarUrl ? (
|
||||
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
|
||||
) : (
|
||||
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typingUsers.length === 1
|
||||
? `${typingUsers[0].username} is typing…`
|
||||
: typingUsers.length === 2
|
||||
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
|
||||
: `${typingUsers.length} people are typing…`}
|
||||
</span>
|
||||
<span className="flex gap-0.5 ml-1">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment list */}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "@/client";
|
||||
import { PRInlineComment } from "./PRInlineComment";
|
||||
import { PRCommentInput } from "./PRCommentInput";
|
||||
import { MiniChat } from "@/components/shared/MiniChat";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -18,7 +19,10 @@ import {
|
||||
Plus,
|
||||
Loader2,
|
||||
File,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PRDiffViewerProps {
|
||||
@ -68,6 +72,7 @@ function FileSection({
|
||||
repoName,
|
||||
prNumber,
|
||||
onRefresh,
|
||||
onOpenChat,
|
||||
}: {
|
||||
file: SideBySideFileResponse;
|
||||
commentMap: LineCommentMap;
|
||||
@ -75,6 +80,7 @@ function FileSection({
|
||||
repoName: string;
|
||||
prNumber: number;
|
||||
onRefresh: () => void;
|
||||
onOpenChat: (path: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [addingComment, setAddingComment] = useState<{
|
||||
@ -158,6 +164,15 @@ function FileSection({
|
||||
-{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenChat(file.path); }}
|
||||
className="ml-1 px-2 py-0.5 text-xs border border-transparent hover:border-border rounded flex items-center gap-1 transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Discuss this file in chat"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -267,6 +282,7 @@ export function PRDiffViewer({
|
||||
head,
|
||||
}: PRDiffViewerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [chatFile, setChatFile] = useState<string | null>(null);
|
||||
|
||||
const { data: diffData, isLoading: diffLoading } = useQuery({
|
||||
queryKey: ["pr-diff-side-by-side", namespace, repoName, prNumber, base, head],
|
||||
@ -345,8 +361,43 @@ export function PRDiffViewer({
|
||||
repoName={repoName}
|
||||
prNumber={prNumber}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenChat={setChatFile}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Discuss-in-chat side panel */}
|
||||
<Sheet open={!!chatFile} onOpenChange={(open) => !open && setChatFile(null)}>
|
||||
<SheetContent side="right" className="w-[420px] p-0 flex flex-col">
|
||||
{chatFile && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
Discussion: {chatFile}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatFile(null)}
|
||||
className="p-1 rounded hover:bg-muted shrink-0 cursor-pointer bg-transparent border-0"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MiniChat
|
||||
roomId={`pr:${namespace}/${repoName}#${prNumber}:${chatFile}`}
|
||||
repoBaseUrl={`/repository/${namespace}/${repoName}`}
|
||||
branch={head}
|
||||
maxHeight={600}
|
||||
className="h-full rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PRCommentInput } from "./PRCommentInput";
|
||||
import { ContentRenderer } from "@/components/shared/ContentRenderer";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
@ -217,7 +218,7 @@ export function PRInlineComment({
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm whitespace-pre-wrap">{comment.body}</div>
|
||||
<ContentRenderer content={comment.body} className="text-sm" />
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CornerUpLeft, Download, FileIcon, FileText, FolderIcon, HardDriveDownload, Loader2, X } from "lucide-react";
|
||||
import { CornerUpLeft, Download, FileIcon, FileText, FolderIcon, HardDriveDownload, Loader2, MessageSquare, X } from "lucide-react";
|
||||
import { gitBlobContent, gitCommitLog, gitTreeList } from "@/client";
|
||||
import { useRepo } from "@/contexts";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { MiniChat } from "@/components/shared/MiniChat";
|
||||
|
||||
type TreeEntry = {
|
||||
name: string;
|
||||
@ -24,6 +25,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<{ path: string; name: string; oid: string } | null>(null);
|
||||
const [previewTab, setPreviewTab] = useState<'content' | 'chat'>('content');
|
||||
|
||||
// Reset to content tab when a new file is opened
|
||||
const handlePreviewFile = useCallback((file: { path: string; name: string; oid: string } | null) => {
|
||||
setPreviewFile(file);
|
||||
setPreviewTab('content');
|
||||
}, []);
|
||||
|
||||
// Get the latest commit to find the tree_id for the current branch
|
||||
const { data: commitsData, isLoading: commitsLoading } = useQuery({
|
||||
@ -81,13 +89,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
||||
} else {
|
||||
// Open file preview for markdown or show download for others
|
||||
if (/\.(md|mdx)$/i.test(entry.name)) {
|
||||
setPreviewFile({ path: currentPath ? `${currentPath}/${entry.name}` : entry.name, name: entry.name, oid: entry.oid });
|
||||
handlePreviewFile({ path: currentPath ? `${currentPath}/${entry.name}` : entry.name, name: entry.name, oid: entry.oid });
|
||||
} else {
|
||||
setSelectedFile(entry.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentPath, updatePath]
|
||||
[currentPath, updatePath, handlePreviewFile]
|
||||
);
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
@ -225,24 +233,47 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-base sm:text-lg truncate">{previewFile.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end shrink-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end shrink-0">
|
||||
{/* Content / Chat tab toggle */}
|
||||
<button
|
||||
onClick={() => setPreviewTab('content')}
|
||||
className={`px-2.5 py-1 text-xs border rounded-md transition-colors ${
|
||||
previewTab === 'content'
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'hover:bg-muted border-transparent'
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewTab('chat')}
|
||||
className={`px-2.5 py-1 text-xs border rounded-md transition-colors flex items-center gap-1 ${
|
||||
previewTab === 'chat'
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'hover:bg-muted border-transparent'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Chat
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={blobLoading}
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted disabled:opacity-50"
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={blobLoading}
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted flex items-center gap-1"
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-muted flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewFile(null)}
|
||||
onClick={() => handlePreviewFile(null)}
|
||||
className="p-1.5 border rounded-md hover:bg-muted"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@ -253,27 +284,42 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
|
||||
{previewFile.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
{blobLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : !blobContent ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">Empty file</p>
|
||||
<p className="text-sm text-muted-foreground">This file has no content</p>
|
||||
|
||||
{previewTab === 'content' ? (
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
{blobLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-sm font-mono bg-muted/50 p-3 sm:p-4 rounded-lg overflow-x-auto max-w-full whitespace-pre-wrap">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{blobContent}
|
||||
</ReactMarkdown>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : !blobContent ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">Empty file</p>
|
||||
<p className="text-sm text-muted-foreground">This file has no content</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-sm font-mono bg-muted/50 p-3 sm:p-4 rounded-lg overflow-x-auto max-w-full whitespace-pre-wrap">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{blobContent}
|
||||
</ReactMarkdown>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-hidden p-0">
|
||||
{repo && (
|
||||
<MiniChat
|
||||
roomId={`repo:${repo.namespace}:${repo.repo_name}:${previewFile.path}`}
|
||||
repoBaseUrl={`/repository/${repo.namespace}/${repo.repo_name}`}
|
||||
branch={branch ?? 'main'}
|
||||
maxHeight={400}
|
||||
className="h-full rounded-none border-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -5,7 +5,7 @@ import type { RoomWithCategory } from '@/contexts/room-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical, BellOff, Archive } from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCorners,
|
||||
@ -29,6 +29,8 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
interface RoomWithUnread extends RoomWithCategory {
|
||||
unread_count?: number;
|
||||
muted?: boolean;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
interface DiscordChannelSidebarProps {
|
||||
@ -40,12 +42,15 @@ interface DiscordChannelSidebarProps {
|
||||
categories: Array<{ id: string; name: string }>;
|
||||
onCreateCategory: (name: string) => Promise<void>;
|
||||
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
|
||||
onMuteRoom?: (roomId: string, muted: boolean) => void;
|
||||
onArchiveRoom?: (roomId: string, archived: boolean) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
type CatName = string;
|
||||
|
||||
const DRAG_PREFIX = 'room:';
|
||||
const CAT_PREFIX = 'cat:';
|
||||
|
||||
/* ── Draggable row ─────────────────────────────────────────────── */
|
||||
|
||||
@ -92,31 +97,66 @@ const RoomButton = memo(function RoomButton({
|
||||
room,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
onMute,
|
||||
onArchive,
|
||||
}: {
|
||||
room: RoomWithCategory;
|
||||
selectedRoomId: string | null;
|
||||
onSelectRoom: (room: RoomWithCategory) => void;
|
||||
onMute?: (roomId: string, muted: boolean) => void;
|
||||
onArchive?: (roomId: string, archived: boolean) => void;
|
||||
}) {
|
||||
const isSelected = selectedRoomId === room.id;
|
||||
const unreadCount = (room as RoomWithUnread).unread_count ?? 0;
|
||||
const meta = room as RoomWithUnread;
|
||||
const unreadCount = meta.unread_count ?? 0;
|
||||
const muted = meta.muted ?? false;
|
||||
const archived = meta.archived ?? false;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(room)}
|
||||
className={cn('discord-channel-item w-full group', isSelected && 'active')}
|
||||
className={cn('discord-channel-item w-full group', isSelected && 'active', archived && 'opacity-50')}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-70 shrink-0 mr-1" />
|
||||
<Hash className="discord-channel-hash" />
|
||||
<span className="discord-channel-name">{room.room_name}</span>
|
||||
{!room.public && (
|
||||
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0 ml-auto" />
|
||||
{!room.public && !muted && !archived && (
|
||||
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0" />
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
{muted && <BellOff className="h-3 w-3 opacity-50 shrink-0 text-amber-500" aria-label="Muted" />}
|
||||
{archived && <Archive className="h-3 w-3 opacity-50 shrink-0 text-muted-foreground" aria-label="Archived" />}
|
||||
{!muted && !archived && unreadCount > 0 && (
|
||||
<span className="discord-mention-badge">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Mute / Archive context actions */}
|
||||
{(onMute || onArchive) && (
|
||||
<div className="ml-auto hidden group-hover:flex items-center gap-0.5">
|
||||
{onMute && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onMute(room.id, !muted); }}
|
||||
className="p-0.5 rounded hover:bg-muted cursor-pointer bg-transparent border-0"
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<BellOff className={cn('h-3 w-3', muted ? 'text-amber-500' : 'text-muted-foreground')} />
|
||||
</button>
|
||||
)}
|
||||
{onArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onArchive(room.id, !archived); }}
|
||||
className="p-0.5 rounded hover:bg-muted cursor-pointer bg-transparent border-0"
|
||||
title={archived ? 'Unarchive' : 'Archive'}
|
||||
>
|
||||
<Archive className={cn('h-3 w-3', archived ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@ -124,6 +164,7 @@ const RoomButton = memo(function RoomButton({
|
||||
/* ── Category group ────────────────────────────────────────────── */
|
||||
|
||||
const ChannelGroup = memo(function ChannelGroup({
|
||||
categoryId,
|
||||
categoryName,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
@ -131,7 +172,10 @@ const ChannelGroup = memo(function ChannelGroup({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
canReceiveDrops,
|
||||
onMute,
|
||||
onArchive,
|
||||
}: {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
rooms: RoomWithCategory[];
|
||||
selectedRoomId: string | null;
|
||||
@ -139,31 +183,66 @@ const ChannelGroup = memo(function ChannelGroup({
|
||||
isCollapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
canReceiveDrops?: true;
|
||||
onMute?: (roomId: string, muted: boolean) => void;
|
||||
onArchive?: (roomId: string, archived: boolean) => void;
|
||||
}) {
|
||||
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
||||
|
||||
// Category header is sortable (for drag-to-reorder categories)
|
||||
const {
|
||||
attributes: catAttrs,
|
||||
listeners: catListeners,
|
||||
setNodeRef: setCatRef,
|
||||
transform: catTransform,
|
||||
transition: catTransition,
|
||||
isDragging: isCatDragging,
|
||||
} = useSortable({ id: `${CAT_PREFIX}${categoryId}` });
|
||||
|
||||
const catStyle: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(catTransform),
|
||||
transition: catTransition,
|
||||
opacity: isCatDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
// Make the category header a droppable zone so rooms can be dragged onto it
|
||||
const { setNodeRef: setHeaderRef, isOver: isOverHeader } = useDroppable({ id: categoryName });
|
||||
|
||||
// Aggregate unread from all rooms in this category
|
||||
const totalUnread = rooms.reduce((acc, r) => acc + ((r as RoomWithUnread).unread_count ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="discord-channel-category"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||
>
|
||||
<button
|
||||
ref={setHeaderRef}
|
||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed', isOverHeader && 'ring-1 ring-accent')}
|
||||
onClick={onToggle}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{categoryName}</span>
|
||||
</button>
|
||||
<div ref={setCatRef} style={catStyle} className="flex items-center">
|
||||
<button
|
||||
ref={setHeaderRef}
|
||||
{...catAttrs}
|
||||
{...catListeners}
|
||||
className={cn(
|
||||
'discord-channel-category-header w-full flex-1',
|
||||
isCollapsed && 'collapsed',
|
||||
isOverHeader && 'ring-1 ring-accent',
|
||||
)}
|
||||
onClick={onToggle}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-70 shrink-0" />
|
||||
<span className="flex-1 text-left">{categoryName}</span>
|
||||
{totalUnread > 0 && (
|
||||
<span className="text-[10px] px-1 rounded-full bg-muted text-muted-foreground">
|
||||
{totalUnread > 99 ? '99+' : totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<ul className="space-y-0.5 pl-2">
|
||||
@ -174,6 +253,8 @@ const ChannelGroup = memo(function ChannelGroup({
|
||||
room={room}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
onMute={onMute}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
</DraggableRow>
|
||||
))}
|
||||
@ -196,6 +277,8 @@ function ChannelListContent({
|
||||
collapsedState,
|
||||
toggleCategory,
|
||||
onMoveRoom,
|
||||
onMute,
|
||||
onArchive,
|
||||
}: {
|
||||
rooms: RoomWithCategory[];
|
||||
selectedRoomId: string | null;
|
||||
@ -206,6 +289,8 @@ function ChannelListContent({
|
||||
collapsedState: Record<string, boolean>;
|
||||
toggleCategory: (name: string) => void;
|
||||
onMoveRoom: (roomId: string, catId: string | null) => void;
|
||||
onMute?: (roomId: string, muted: boolean) => void;
|
||||
onArchive?: (roomId: string, archived: boolean) => void;
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
@ -224,6 +309,14 @@ function ChannelListContent({
|
||||
if (!over) return;
|
||||
|
||||
const dragId = String(event.active.id);
|
||||
|
||||
// Category reorder
|
||||
if (dragId.startsWith(CAT_PREFIX)) {
|
||||
// Category reordering — emit an event if the parent component manages order
|
||||
// Currently categories don't have a backend order API; handled by parent
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragId.startsWith(DRAG_PREFIX)) return;
|
||||
|
||||
const draggedRoomId = dragId.slice(DRAG_PREFIX.length);
|
||||
@ -254,26 +347,35 @@ function ChannelListContent({
|
||||
{/* Uncategorized channels at top */}
|
||||
{uncategorizedRooms.length > 0 && (
|
||||
<ChannelGroup
|
||||
categoryId="__uncategorized__"
|
||||
categoryName="Channels"
|
||||
rooms={uncategorizedRooms}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
onMute={onMute}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Categorized groups */}
|
||||
{sortedCatNames.map((catName) => (
|
||||
<ChannelGroup
|
||||
key={catName}
|
||||
categoryName={catName}
|
||||
rooms={categorizedRooms.get(catName)!}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
isCollapsed={!!collapsedState[catName]}
|
||||
onToggle={() => toggleCategory(catName)}
|
||||
canReceiveDrops
|
||||
/>
|
||||
))}
|
||||
{sortedCatNames.map((catName) => {
|
||||
const cat = categories.find((c) => c.name === catName);
|
||||
return (
|
||||
<ChannelGroup
|
||||
key={catName}
|
||||
categoryId={cat?.id ?? catName}
|
||||
categoryName={catName}
|
||||
rooms={categorizedRooms.get(catName)!}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
isCollapsed={!!collapsedState[catName]}
|
||||
onToggle={() => toggleCategory(catName)}
|
||||
canReceiveDrops
|
||||
onMute={onMute}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@ -289,6 +391,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
||||
categories,
|
||||
onCreateCategory,
|
||||
onMoveRoomToCategory,
|
||||
onMuteRoom,
|
||||
onArchiveRoom,
|
||||
onOpenSettings,
|
||||
}: DiscordChannelSidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
@ -380,6 +484,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
||||
collapsedState={collapsed}
|
||||
toggleCategory={toggleCategory}
|
||||
onMoveRoom={handleMoveRoom}
|
||||
onMute={onMuteRoom}
|
||||
onArchive={onArchiveRoom}
|
||||
/>
|
||||
|
||||
{rooms.length === 0 && (
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useUser } from '@/contexts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Edit, MoreHorizontal, Reply, Trash2 } from 'lucide-react';
|
||||
import { AlertCircle, Copy, Edit, GitPullRequest, LayoutDashboard, MoreHorizontal, Reply, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getSenderUserUid } from './sender';
|
||||
@ -62,6 +62,76 @@ export function RoomMessageActions({ message, onEdit, onRevoke, onReply }: RoomM
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{message.content_type === 'text' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
toast.info('Creating issue from message…', {
|
||||
description: 'This will open the issue creation form with the message content pre-filled.',
|
||||
action: {
|
||||
label: 'Create',
|
||||
onClick: () => {
|
||||
// TODO: wire to POST /api/issue/{project}/issues/from-message
|
||||
const title = message.content.split('\n')[0].slice(0, 80);
|
||||
const body = `Converted from room message (${message.id})\n\n${message.content}`;
|
||||
const params = new URLSearchParams({ title, body });
|
||||
window.open(`/project/-/issues/new?${params}`, '_blank');
|
||||
},
|
||||
},
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
Create Issue
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{message.content_type === 'text' && /\n```[\s\S]*?\n```/.test(message.content) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
toast.info('Creating PR from message…', {
|
||||
description: 'Open the PR creation form with the code snippet pre-filled.',
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: () => {
|
||||
// TODO: wire to POST /api/repo_pr/{ns}/{repo}/pulls/from-message
|
||||
const params = new URLSearchParams({
|
||||
body: `Converted from room message (${message.id})\n\n${message.content}`,
|
||||
});
|
||||
window.open(`/pulls/new?${params}`, '_blank');
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<GitPullRequest className="mr-2 h-4 w-4" />
|
||||
Create PR
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{message.content_type === 'text' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
toast.info('Adding to board…', {
|
||||
description: 'Open the kanban board with this message as the card description.',
|
||||
action: {
|
||||
label: 'Open Board',
|
||||
onClick: () => {
|
||||
// TODO: wire to POST /api/board/cards
|
||||
const params = new URLSearchParams({
|
||||
title: message.content.split('\n')[0].slice(0, 80),
|
||||
description: message.content,
|
||||
});
|
||||
window.open(`/boards/new?${params}`, '_blank');
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Add to Board
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isOwner && onEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@ -1,12 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pin, X, Loader2 } from 'lucide-react';
|
||||
import { Pin, X, Loader2, Search, Info, Link, BookMarked, Archive } from 'lucide-react';
|
||||
import { pinList, pinRemove, type RoomPinResponse, type RoomMemberResponse } from '@/client';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { getSenderDisplayName } from './sender';
|
||||
import { formatMessageTime } from './shared/formatters';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Category types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type PinCategory = 'all' | 'info' | 'resources' | 'rules' | 'archive';
|
||||
|
||||
interface CategorizedPin extends RoomPinResponse {
|
||||
category: PinCategory;
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<Exclude<PinCategory, 'all'>, { label: string; Icon: React.ComponentType<{ className?: string }>; color: string }> = {
|
||||
info: { label: 'Info', Icon: Info, color: 'text-blue-500' },
|
||||
resources: { label: 'Resources', Icon: Link, color: 'text-green-500' },
|
||||
rules: { label: 'Rules', Icon: BookMarked, color: 'text-amber-500' },
|
||||
archive: { label: 'Archive', Icon: Archive, color: 'text-muted-foreground' },
|
||||
};
|
||||
|
||||
// ── Smart categorization ─────────────────────────────────────────────────────
|
||||
|
||||
function categorizePin(pin: RoomPinResponse): PinCategory {
|
||||
const content = (pin as unknown as { content?: string }).content ?? '';
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
if (
|
||||
/https?:\/\/|github\.com|bitbucket\.org|gitlab\.com|docs\.|readme|wiki|spec|rfc|md\b/i.test(lower) ||
|
||||
/@[a-z]+(?:\/[a-z]+){0,2}\b/.test(content) // repo/project mentions
|
||||
) {
|
||||
return 'resources';
|
||||
}
|
||||
if (
|
||||
/\b(do not|must not|please remember|always|never|rule|guideline|policy|forbidden|required|ensure that|make sure)\b/i.test(lower)
|
||||
) {
|
||||
return 'rules';
|
||||
}
|
||||
|
||||
// Archive pins older than 30 days
|
||||
const age = Date.now() - new Date(pin.pinned_at).getTime();
|
||||
if (age > 30 * 24 * 60 * 60 * 1000) {
|
||||
return 'archive';
|
||||
}
|
||||
return 'info';
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RoomPinPanelProps {
|
||||
roomId: string;
|
||||
@ -24,13 +69,21 @@ interface RoomPinPanelProps {
|
||||
|
||||
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeCategory, setActiveCategory] = useState<PinCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: pins, isLoading } = useQuery({
|
||||
queryKey: ['roomPins', roomId],
|
||||
queryFn: async () => {
|
||||
const resp = await pinList({ path: { room_id: roomId } });
|
||||
return resp.data?.data ?? ([] as RoomPinResponse[]);
|
||||
const raw = resp.data?.data ?? ([] as RoomPinResponse[]);
|
||||
// Attach content from local messages for categorization
|
||||
return raw.map((p) => ({
|
||||
...p,
|
||||
content: messages.find((m) => m.id === p.message)?.content ?? '',
|
||||
})) as Array<RoomPinResponse & { content: string }>;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const unpinMutation = useMutation({
|
||||
@ -46,8 +99,35 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map of message_id -> message content from local messages
|
||||
const messageMap = new Map(messages.map(m => [m.id, m]));
|
||||
const messageMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
|
||||
const categorizedPins = useMemo<CategorizedPin[]>(() => {
|
||||
if (!pins) return [];
|
||||
return pins.map((p) => ({
|
||||
...p,
|
||||
category: categorizePin(p as unknown as RoomPinResponse),
|
||||
}));
|
||||
}, [pins]);
|
||||
|
||||
const filteredPins = useMemo(() => {
|
||||
return categorizedPins.filter((p) => {
|
||||
const matchesCategory = activeCategory === 'all' || p.category === activeCategory;
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
(messageMap.get(p.message)?.content ?? '').toLowerCase().includes(search.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [categorizedPins, activeCategory, search, messageMap]);
|
||||
|
||||
const countByCategory = useMemo(() => {
|
||||
const counts: Record<PinCategory, number> = { all: categorizedPins.length, info: 0, resources: 0, rules: 0, archive: 0 };
|
||||
for (const p of categorizedPins) {
|
||||
if (p.category !== 'all') counts[p.category]++;
|
||||
}
|
||||
return counts;
|
||||
}, [categorizedPins]);
|
||||
|
||||
const categories: PinCategory[] = ['all', 'info', 'resources', 'rules', 'archive'];
|
||||
|
||||
return (
|
||||
<aside
|
||||
@ -70,7 +150,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
<div className="flex items-center gap-2">
|
||||
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
||||
Pinned Messages
|
||||
Pinned
|
||||
</span>
|
||||
{pins && (
|
||||
<span
|
||||
@ -90,21 +170,72 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2 border-b" style={{ borderColor: 'var(--room-border)' }}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--room-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search pins…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full h-7 pl-7 pr-3 rounded text-[12px] bg-transparent border-0 outline-none placeholder:text-[var(--room-text-muted)]"
|
||||
style={{ color: 'var(--room-text)', background: 'var(--room-channel-active)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div
|
||||
className="flex items-center gap-1 px-4 py-1.5 border-b overflow-x-auto shrink-0"
|
||||
style={{ borderColor: 'var(--room-border)' }}
|
||||
>
|
||||
{categories.map((cat) => {
|
||||
const meta = cat === 'all' ? null : CATEGORY_META[cat];
|
||||
const count = countByCategory[cat];
|
||||
if (cat !== 'all' && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium whitespace-nowrap transition-colors cursor-pointer border-0 bg-transparent',
|
||||
activeCategory === cat
|
||||
? 'text-[var(--room-accent)]'
|
||||
: 'text-[var(--room-text-muted)] hover:text-[var(--room-text)]',
|
||||
)}
|
||||
>
|
||||
{meta && <meta.Icon className={cn('h-3 w-3', meta.color)} />}
|
||||
{cat === 'all' ? 'All' : meta?.label}
|
||||
<span
|
||||
className="ml-0.5 px-1 rounded-full text-[10px]"
|
||||
style={{ background: activeCategory === cat ? 'var(--room-channel-active)' : 'transparent' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
||||
</div>
|
||||
) : pins && pins.length === 0 ? (
|
||||
) : filteredPins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 gap-2">
|
||||
<Pin className="h-8 w-8 opacity-30" style={{ color: 'var(--room-text-muted)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>No pinned messages</p>
|
||||
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>
|
||||
{search ? 'No matching pins' : 'No pinned messages'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
|
||||
{pins?.map((pin) => {
|
||||
{filteredPins.map((pin) => {
|
||||
const localMsg = messageMap.get(pin.message);
|
||||
const catMeta = pin.category === 'all' ? null : CATEGORY_META[pin.category];
|
||||
return (
|
||||
<div
|
||||
key={pin.message}
|
||||
@ -115,10 +246,13 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
}}
|
||||
>
|
||||
{/* Sender row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{catMeta && (
|
||||
<catMeta.Icon className={cn('h-3 w-3 shrink-0', catMeta.color)} />
|
||||
)}
|
||||
<Avatar className="h-5 w-5">
|
||||
{(() => {
|
||||
const member = members.find(m => m.user === pin.pinned_by);
|
||||
const member = members.find((m) => m.user === pin.pinned_by);
|
||||
return (
|
||||
<>
|
||||
{member?.user_info?.avatar_url ? (
|
||||
@ -133,12 +267,12 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
</Avatar>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--room-text)' }}>
|
||||
{(() => {
|
||||
const member = members.find(m => m.user === pin.pinned_by);
|
||||
const member = members.find((m) => m.user === pin.pinned_by);
|
||||
return member?.user_info?.username ?? pin.pinned_by;
|
||||
})()}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
||||
pinned {formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
|
||||
{formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@ -154,7 +288,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
|
||||
{/* Message preview */}
|
||||
<p
|
||||
className="text-[13px] line-clamp-2 pl-7"
|
||||
className="text-[13px] line-clamp-2 pl-5"
|
||||
style={{ color: 'var(--room-text-secondary)' }}
|
||||
>
|
||||
{localMsg
|
||||
@ -166,9 +300,9 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
|
||||
|
||||
{/* Original sender */}
|
||||
{localMsg && (
|
||||
<div className="mt-0.5 pl-7">
|
||||
<div className="mt-0.5 pl-5">
|
||||
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
||||
{getSenderDisplayName(localMsg as any)} • {formatMessageTime(localMsg.send_at)}
|
||||
{getSenderDisplayName(localMsg as Parameters<typeof getSenderDisplayName>[0])} • {formatMessageTime(localMsg.send_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Renders message content — markdown with @[type:id:label] mentions.
|
||||
* Mentions are protected from markdown parsing by replacing them with
|
||||
* placeholder tokens before rendering, then restored in custom text components.
|
||||
* Renders room message content — markdown with @[type:id:label] mentions,
|
||||
* plus code-aware features: smart link previews and code references.
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ContentRenderer } from '@/components/shared/ContentRenderer';
|
||||
import { LinkPreview } from '@/components/shared/LinkPreview';
|
||||
import { CodeReference } from '@/components/shared/CodeReference';
|
||||
import { extractUrls, detectLinkType, type UnfurlResult } from '@/lib/link-unfurl';
|
||||
import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageContentProps {
|
||||
@ -16,178 +18,74 @@ interface MessageContentProps {
|
||||
onMentionClick?: (type: string, id: string, label: string) => void;
|
||||
}
|
||||
|
||||
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
||||
|
||||
interface MentionInfo {
|
||||
type: string;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
|
||||
function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
|
||||
const mentions: MentionInfo[] = [];
|
||||
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
|
||||
const idx = mentions.length;
|
||||
mentions.push({ type, id, label });
|
||||
return `\u200BMENTION_${idx}\u200B`; // zero-width spaces prevent markdown parsing
|
||||
});
|
||||
return { safeContent, mentions };
|
||||
}
|
||||
|
||||
function getMentionStyle(type: string): string {
|
||||
switch (type) {
|
||||
case 'user': return 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'channel': return 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
|
||||
case 'ai': return 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'command': return 'bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
default: return 'bg-muted text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore mention placeholders inside a text node into React elements */
|
||||
function restoreMentions(text: string, mentions: MentionInfo[], onMentionClick?: (type: string, id: string, label: string) => void): React.ReactNode[] {
|
||||
const MENTION_PLACEHOLDER_RE = /\u200BMENTION_(\d+)\u200B/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = MENTION_PLACEHOLDER_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
export const MessageContent = memo(function MessageContent({
|
||||
content,
|
||||
onMentionClick,
|
||||
}: MessageContentProps) {
|
||||
// Extract standalone URLs for link preview rendering
|
||||
const urlResults = useMemo<UnfurlResult[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const results: UnfurlResult[] = [];
|
||||
for (const { url } of extractUrls(content)) {
|
||||
if (seen.has(url)) continue;
|
||||
const result = detectLinkType(url);
|
||||
if (result && !result.isExternal) {
|
||||
seen.add(url);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
const idx = parseInt(match[1], 10);
|
||||
const m = mentions[idx];
|
||||
if (m) {
|
||||
parts.push(
|
||||
<span
|
||||
key={`mention-${idx}`}
|
||||
role={onMentionClick ? 'button' : undefined}
|
||||
tabIndex={onMentionClick ? 0 : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
||||
getMentionStyle(m.type),
|
||||
)}
|
||||
onClick={() => onMentionClick?.(m.type, m.id, m.label)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
||||
e.preventDefault();
|
||||
onMentionClick(m.type, m.id, m.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@{m.label}
|
||||
</span>,
|
||||
);
|
||||
return results;
|
||||
}, [content]);
|
||||
|
||||
// Extract code references (file.rs:42 style)
|
||||
const codeRefs = useMemo<CodeRef[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const refs: CodeRef[] = [];
|
||||
const matches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
const parsed = parseCodeRef(match);
|
||||
if (parsed) {
|
||||
seen.add(match);
|
||||
refs.push(parsed);
|
||||
}
|
||||
}
|
||||
lastIndex = MENTION_PLACEHOLDER_RE.lastIndex;
|
||||
}
|
||||
return refs;
|
||||
}, [content]);
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export const MessageContent = memo(function MessageContent({ content, onMentionClick }: MessageContentProps) {
|
||||
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
|
||||
const hasLinkPreviews = urlResults.length > 0;
|
||||
const hasCodeRefs = codeRefs.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-[15px] text-foreground',
|
||||
'max-w-full min-w-0 break-words',
|
||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
||||
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
|
||||
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
|
||||
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
|
||||
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
|
||||
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
|
||||
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
|
||||
'[&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
||||
'[&_hr]:border-foreground/20 [&_hr]:my-2',
|
||||
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
|
||||
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
|
||||
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
|
||||
'[&_tr]:border-t [&_tr]:even:bg-muted',
|
||||
<div className={cn('space-y-2', !hasLinkPreviews && !hasCodeRefs && 'space-y-0')}>
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
onMentionClick={onMentionClick}
|
||||
/>
|
||||
|
||||
{/* Code references */}
|
||||
{hasCodeRefs && (
|
||||
<div className="space-y-1">
|
||||
{codeRefs.map((ref, i) => (
|
||||
<CodeReference
|
||||
key={`ref-${i}-${ref.raw}`}
|
||||
ref={ref}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link previews */}
|
||||
{hasLinkPreviews && (
|
||||
<div className="space-y-1">
|
||||
{urlResults.map((result, i) => (
|
||||
<LinkPreview
|
||||
key={`link-${i}-${result.url}`}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => {
|
||||
// Restore mentions in paragraph text nodes
|
||||
if (typeof children === 'string') {
|
||||
return <p>{restoreMentions(children, mentions, onMentionClick)}</p>;
|
||||
}
|
||||
// Children may be an array of strings/elements
|
||||
if (Array.isArray(children)) {
|
||||
const restored = children.map((child) => {
|
||||
if (typeof child === 'string') {
|
||||
return restoreMentions(child, mentions, onMentionClick);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
return <p>{restored}</p>;
|
||||
}
|
||||
return <p>{children}</p>;
|
||||
},
|
||||
li: ({ children }) => {
|
||||
if (typeof children === 'string') {
|
||||
return <li>{restoreMentions(children, mentions, onMentionClick)}</li>;
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
const restored = children.map((child) => {
|
||||
if (typeof child === 'string') {
|
||||
return restoreMentions(child, mentions, onMentionClick);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
return <li>{restored}</li>;
|
||||
}
|
||||
return <li>{children}</li>;
|
||||
},
|
||||
strong: ({ children }) => {
|
||||
if (typeof children === 'string') {
|
||||
return <strong>{restoreMentions(children, mentions, onMentionClick)}</strong>;
|
||||
}
|
||||
return <strong>{children}</strong>;
|
||||
},
|
||||
em: ({ children }) => {
|
||||
if (typeof children === 'string') {
|
||||
return <em>{restoreMentions(children, mentions, onMentionClick)}</em>;
|
||||
}
|
||||
return <em>{children}</em>;
|
||||
},
|
||||
code: ({ className, children, ...props }) => {
|
||||
// Inline code — don't restore mentions inside code blocks
|
||||
const isBlock = typeof className === 'string' && className.includes('language-');
|
||||
if (isBlock) {
|
||||
// Fenced code block — let the pre wrapper handle it
|
||||
return <code className={className} {...props}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="font-mono rounded bg-muted px-1 py-0.5 text-xs"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => {
|
||||
// Preserve code blocks as-is, no mention restoration
|
||||
return <pre className="rounded-md bg-muted p-3 overflow-x-auto">{children}</pre>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{safeContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -29,6 +29,8 @@ export interface RepoInfo {
|
||||
ssh_clone_url: string;
|
||||
/** HTTPS clone URL */
|
||||
https_clone_url: string;
|
||||
/** Whether AI auto-review is enabled for this repo */
|
||||
ai_code_review_enabled: boolean;
|
||||
}
|
||||
|
||||
export const RepositoryContext = React.createContext<RepoInfo | null>(null);
|
||||
@ -101,6 +103,7 @@ export const RepositoryContextProvider = ({
|
||||
is_watch: Boolean(watchCountResp),
|
||||
ssh_clone_url: repoItem.ssh_clone_url,
|
||||
https_clone_url: repoItem.https_clone_url,
|
||||
ai_code_review_enabled: repoItem.ai_code_review_enabled,
|
||||
};
|
||||
}, [repoItem, namespace, starCountResp, watchCountResp]);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user