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:
ZhenYi 2026-04-25 09:54:05 +08:00
parent dfa5f7664a
commit 99bc4eeb80
28 changed files with 2401 additions and 539 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]);