diff --git a/libs/api/agent/mod.rs b/libs/api/agent/mod.rs index 1a587aa..ad7d46e 100644 --- a/libs/api/agent/mod.rs +++ b/libs/api/agent/mod.rs @@ -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), diff --git a/libs/api/issue/issue_label.rs b/libs/api/issue/issue_label.rs index 13958c3..b8ca7f8 100644 --- a/libs/api/issue/issue_label.rs +++ b/libs/api/issue/issue_label.rs @@ -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>), + (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, + session: Session, + path: web::Path<(String, i64)>, + body: web::Json, +) -> Result { + 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()) +} diff --git a/libs/api/issue/mod.rs b/libs/api/issue/mod.rs index 910ad6c..6004da0 100644 --- a/libs/api/issue/mod.rs +++ b/libs/api/issue/mod.rs @@ -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), diff --git a/libs/api/openapi.rs b/libs/api/openapi.rs index d23eafa..4f902f5 100644 --- a/libs/api/openapi.rs +++ b/libs/api/openapi.rs @@ -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( diff --git a/libs/api/room/ws_universal.rs b/libs/api/room/ws_universal.rs index 4650ab4..b7712e5 100644 --- a/libs/api/room/ws_universal.rs +++ b/libs/api/room/ws_universal.rs @@ -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, }, + Notification { + event: Arc, + }, } /// 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() => { diff --git a/libs/api/search/mod.rs b/libs/api/search/mod.rs index d36046e..9b00e9a 100644 --- a/libs/api/search/mod.rs +++ b/libs/api/search/mod.rs @@ -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)); } diff --git a/libs/api/search/service.rs b/libs/api/search/service.rs index 186ff27..aed67bc 100644 --- a/libs/api/search/service.rs +++ b/libs/api/search/service.rs @@ -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, Query, description = "Page number, default 1"), + ("per_page" = Option, Query, description = "Results per page, default 20, max 100"), + ), + responses( + (status = 200, description = "Message search results across all accessible rooms", body = ApiResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + tag = "Search" +)] +pub async fn search_messages( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let resp = service.global_message_search(&session, query.into_inner()).await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/room/src/notification.rs b/libs/room/src/notification.rs index b031fd8..5ce2ffc 100644 --- a/libs/room/src/notification.rs +++ b/libs/room/src/notification.rs @@ -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() +} diff --git a/libs/room/src/types.rs b/libs/room/src/types.rs index 1a6b6d3..e251ad8 100644 --- a/libs/room/src/types.rs +++ b/libs/room/src/types.rs @@ -369,6 +369,7 @@ pub struct NotificationListResponse { pub struct NotificationEvent { pub event_type: String, pub notification: NotificationResponse, + pub deep_link_url: Option, pub timestamp: DateTime, } @@ -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 + } } diff --git a/openapi.json b/openapi.json index 2adf5f4..92534cb 100644 --- a/openapi.json +++ b/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" } } }, diff --git a/src/app/notify/page.tsx b/src/app/notify/page.tsx index 9a3eda0..8cd08d6 100644 --- a/src/app/notify/page.tsx +++ b/src/app/notify/page.tsx @@ -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: , color: "bg-blue-500/10 text-blue-600 border-blue-500/20" }, - invitation: { label: "Invitation", icon: , color: "bg-purple-500/10 text-purple-600 border-purple-500/20" }, - role_change: { label: "Role Change", icon: , color: "bg-orange-500/10 text-orange-600 border-orange-500/20" }, - room_created: { label: "Room Created", icon: , color: "bg-green-500/10 text-green-600 border-green-500/20" }, - room_deleted: { label: "Room Deleted", icon: , color: "bg-red-500/10 text-red-600 border-red-500/20" }, - system_announcement: { label: "Announcement", icon: , color: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20" }, + mention: { + label: 'Mention', + icon: , + color: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + }, + invitation: { + label: 'Invitation', + icon: , + color: 'bg-purple-500/10 text-purple-600 border-purple-500/20', + }, + project_invitation: { + label: 'Project Invite', + icon: , + color: 'bg-purple-500/10 text-purple-600 border-purple-500/20', + }, + workspace_invitation: { + label: 'Workspace Invite', + icon: , + color: 'bg-purple-500/10 text-purple-600 border-purple-500/20', + }, + role_change: { + label: 'Role Change', + icon: , + color: 'bg-orange-500/10 text-orange-600 border-orange-500/20', + }, + room_created: { + label: 'Room Created', + icon: , + color: 'bg-green-500/10 text-green-600 border-green-500/20', + }, + room_deleted: { + label: 'Room Deleted', + icon: , + color: 'bg-red-500/10 text-red-600 border-red-500/20', + }, + system_announcement: { + label: 'Announcement', + icon: , + color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20', + }, + issue_opened: { + label: 'Issue Opened', + icon: , + color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + }, + issue_commented: { + label: 'Issue Comment', + icon: , + color: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + }, + issue_closed: { + label: 'Issue Closed', + icon: , + color: 'bg-violet-500/10 text-violet-600 border-violet-500/20', + }, + pr_review_requested: { + label: 'Review Requested', + icon: , + color: 'bg-amber-500/10 text-amber-600 border-amber-500/20', + }, + pr_approved: { + label: 'PR Approved', + icon: , + color: 'bg-green-500/10 text-green-600 border-green-500/20', + }, + pr_merged: { + label: 'PR Merged', + icon: , + 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: , - 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 ( -
, + color: 'bg-muted text-muted-foreground border-border', + }; + + return ( +
+ {/* Unread dot */} +
+ {!n.is_read &&
} +
+ + {/* Icon */} +
+ {config.icon} +
+ + {/* Content */} +
+ +
- {/* Icon */} -
- {config.icon} -
+ {/* Actions */} +
+ {!n.is_read && ( + + )} + +
+
+ ); +} - {/* Content */} -
-
- -
-
+interface NotificationGroupProps { + title: string; + count: number; + defaultOpen?: boolean; + children: React.ReactNode; +} - {/* Actions */} -
- {!n.is_read && ( - - )} - -
-
- ); +function NotificationGroup({ title, count, defaultOpen = true, children }: NotificationGroupProps) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open && children} +
+ ); } export default function NotifyPage() { - const queryClient = useQueryClient(); - const [filter, setFilter] = useState("all"); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [filter, setFilter] = useState('all'); + const [groupBy, setGroupBy] = useState('none'); + const [selectedIds, setSelectedIds] = useState>(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(); + 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(); + 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 ( -
- {/* Header */} -
-
-

- - Notifications -

-

- {unreadCount > 0 - ? `${unreadCount} unread · ${total} total` - : `${total} notification${total !== 1 ? "s" : ""}`} -

-
- {unreadCount > 0 && filter !== "archived" && ( - - )} -
+ const total = filteredNotifications.length; - {/* Filter tabs */} -
- {(["all", "unread", "archived"] as Filter[]).map((f) => ( - - ))} -
- - {/* List */} -
- {isLoading ? ( -
- -
- ) : notifications.length === 0 ? ( -
- -

- {filter === "unread" ? "No unread notifications" : filter === "archived" ? "No archived notifications" : "No notifications yet"} -

-

- {filter === "unread" - ? "You're all caught up!" - : filter === "archived" - ? "Archived notifications will appear here." - : "You'll see notifications here when something happens."} -

-
- ) : ( - notifications.map((n) => ( - markReadMutation.mutate(id)} - onArchive={(id) => archiveMutation.mutate(id)} - /> - )) - )} -
+ return ( +
+ {/* Header */} +
+
+

+ + Notifications + {isLive && ( + + + Live + + )} +

+

+ {unreadCount > 0 + ? `${unreadCount} unread · ${total} total` + : `${total} notification${total !== 1 ? 's' : ''}`} +

- ); +
+ {/* Group by selector */} +
+ + +
+ + {batchMode ? ( + <> + + + + + ) : ( + <> + {unreadCount > 0 && filter !== 'archived' && ( + + )} + + + )} +
+
+ + {/* Filter tabs */} +
+ {(['all', 'unread', 'archived'] as Filter[]).map((f) => ( + + ))} + + {/* Batch mode toggle */} +
+ +
+
+ + {/* List */} +
+ {total === 0 ? ( +
+
+ +

+ {filter === 'unread' + ? 'No unread notifications' + : filter === 'archived' + ? 'No archived notifications' + : 'No notifications yet'} +

+

+ {filter === 'unread' + ? "You're all caught up!" + : filter === 'archived' + ? 'Archived notifications will appear here.' + : "You'll see notifications here when something happens."} +

+
+
+ ) : groupBy !== 'none' ? ( + groups.map(({ title, items }) => ( + + {items.map((n) => ( +
+ {batchMode && ( + toggleSelect(n.id)} + /> + )} +
+ +
+
+ ))} +
+ +
+
+ )) + ) : ( +
+ {filteredNotifications.map((n) => ( +
+ {batchMode && ( + toggleSelect(n.id)} + /> + )} +
+ +
+
+ ))} +
+ )} +
+
+ ); } diff --git a/src/app/project/issue-detail.tsx b/src/app/project/issue-detail.tsx index 320c962..2605fc7 100644 --- a/src/app/project/issue-detail.tsx +++ b/src/app/project/issue-detail.tsx @@ -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(null); const [editingContent, setEditingContent] = useState(''); + const typingStopTimer = useRef | 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 && (
-

- {issue.body} -

+ +
+ )} + + {/* AI Triage Suggestions */} + {triageData?.suggestions && !triageDismissed && ( +
+
+ +
+
+ + AI Triage + + + {triageData.suggestions.priority} priority + +
+

+ {triageData.suggestions.reasoning} +

+ {triageData.suggestions.suggested_labels.length > 0 && ( +
+ {triageData.suggestions.suggested_labels.map((label) => ( + + {label} + + ))} +
+ )} +
+ + +
+
+
)}
@@ -395,9 +500,7 @@ export function IssueDetail() {
) : ( -

- {comment.body} -

+ )} @@ -415,7 +518,12 @@ export function IssueDetail() {