feat(frontend): Discord layout + AI Studio theme + Room Settings
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

Frontend:
- Add Discord-style 3-column layout (server icons / channel sidebar / chat)
- AI Studio design system: new CSS token palette (--room-* vars)
- Replace all hardcoded Discord colors with CSS variable tokens
- Add RoomSettingsPanel (name, visibility, AI model management)
- Settings + Member list panels mutually exclusive (don't overlap)
- AI models shown at top of member list with green accent
- Fix TS errors: TipTap SuggestionOptions, unused imports, StarterKit options
- Remove MentionInput, MentionPopover, old room components (废弃代码清理)

Backend:
- RoomAiResponse returns model_name from agents.model JOIN
- room_ai_list and room_ai_upsert fetch model name for each config
- AiConfigData ws-protocol interface updated with model_name

Note: RoomSettingsPanel UI still uses shadcn defaults (未完全迁移到AI Studio)
This commit is contained in:
ZhenYi 2026-04-18 16:59:36 +08:00
parent aac32b1b92
commit 00a5369fe1
58 changed files with 4803 additions and 2319 deletions

1
.next/cache/.previewinfo vendored Normal file
View File

@ -0,0 +1 @@
{"previewModeId":"ce3f288bfc676d22dc4c8451698f43ff","previewModeSigningKey":"07f92b6308a6126f36d71e78252c52e029f15d27f2d9eb156ebeba1b7c17b831","previewModeEncryptionKey":"efdc9600f7d4bcef633ac1e3be3348b32564789ea4916ea5adafb5b512cbe456","expireAt":1777702293556}

1
.next/cache/.rscinfo vendored Normal file
View File

@ -0,0 +1 @@
{"encryption.key":"Hqnv4g8ZhwVTYVrYYvM4IYBPhIetrEjilPilE28S0JY=","encryption.expire_at":1777702293517}

View File

@ -0,0 +1,6 @@
{
"buildStage": "compile",
"buildOptions": {
"useBuildWorker": "true"
}
}

View File

@ -0,0 +1 @@
{"name":"Next.js","version":"16.2.4"}

1
.next/package.json Normal file
View File

@ -0,0 +1 @@
{"type": "commonjs"}

1
.next/trace Normal file
View File

@ -0,0 +1 @@
[{"name":"generate-buildid","duration":368,"timestamp":609332875152,"id":4,"parentId":1,"tags":{},"startTime":1776492693501,"traceId":"4419ade081f7a7a5"},{"name":"load-custom-routes","duration":826,"timestamp":609332875696,"id":5,"parentId":1,"tags":{},"startTime":1776492693501,"traceId":"4419ade081f7a7a5"},{"name":"create-dist-dir","duration":1467,"timestamp":609332876570,"id":6,"parentId":1,"tags":{},"startTime":1776492693502,"traceId":"4419ade081f7a7a5"},{"name":"clean","duration":1069,"timestamp":609332880708,"id":7,"parentId":1,"tags":{},"startTime":1776492693506,"traceId":"4419ade081f7a7a5"},{"name":"discover-routes","duration":11320,"timestamp":609332931364,"id":8,"parentId":1,"tags":{},"startTime":1776492693557,"traceId":"4419ade081f7a7a5"},{"name":"create-root-mapping","duration":180,"timestamp":609332942834,"id":9,"parentId":1,"tags":{},"startTime":1776492693568,"traceId":"4419ade081f7a7a5"},{"name":"generate-route-types","duration":24323,"timestamp":609332945177,"id":10,"parentId":1,"tags":{},"startTime":1776492693571,"traceId":"4419ade081f7a7a5"},{"name":"public-dir-conflict-check","duration":185,"timestamp":609332969855,"id":11,"parentId":1,"tags":{},"startTime":1776492693595,"traceId":"4419ade081f7a7a5"},{"name":"generate-routes-manifest","duration":2802,"timestamp":609332970199,"id":12,"parentId":1,"tags":{},"startTime":1776492693596,"traceId":"4419ade081f7a7a5"},{"name":"run-turbopack","duration":549715,"timestamp":609332984159,"id":14,"parentId":1,"tags":{"failed":true},"startTime":1776492693610,"traceId":"4419ade081f7a7a5"},{"name":"next-build","duration":810890,"timestamp":609332723082,"id":1,"tags":{"buildMode":"default","version":"16.2.4","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true","failed":true},"startTime":1776492693349,"traceId":"4419ade081f7a7a5"}]

1
.next/trace-build Normal file
View File

@ -0,0 +1 @@
[{"name":"run-turbopack","duration":549715,"timestamp":609332984159,"id":14,"parentId":1,"tags":{"failed":true},"startTime":1776492693610,"traceId":"4419ade081f7a7a5"},{"name":"next-build","duration":810890,"timestamp":609332723082,"id":1,"tags":{"buildMode":"default","version":"16.2.4","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true","failed":true},"startTime":1776492693349,"traceId":"4419ade081f7a7a5"}]

0
.next/turbopack Normal file
View File

145
.next/types/cache-life.d.ts vendored Normal file
View File

@ -0,0 +1,145 @@
// Type definitions for Next.js cacheLife configs
declare module 'next/cache' {
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export {
updateTag,
revalidateTag,
revalidatePath,
refresh,
} from 'next/dist/server/web/spec-extension/revalidate'
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
/**
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 900 seconds (15 minutes)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function cacheLife(profile: "default"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
* ```
* stale: 30 seconds
* revalidate: 1 seconds
* expire: 60 seconds (1 minute)
* ```
*
* This cache may be stale on clients for 30 seconds before checking with the server.
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "seconds"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 60 seconds (1 minute)
* expire: 3600 seconds (1 hour)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 minute, start revalidating new values in the background.
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "minutes"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 3600 seconds (1 hour)
* expire: 86400 seconds (1 day)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 hour, start revalidating new values in the background.
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "hours"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 86400 seconds (1 day)
* expire: 604800 seconds (1 week)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 day, start revalidating new values in the background.
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "days"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 604800 seconds (1 week)
* expire: 2592000 seconds (1 month)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 week, start revalidating new values in the background.
* If this entry has no traffic for 1 month it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "weeks"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 2592000 seconds (1 month)
* expire: 31536000 seconds (365 days)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 month, start revalidating new values in the background.
* If this entry has no traffic for 365 days it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "max"): void
/**
* Cache this `"use cache"` using a custom timespan.
* ```
* stale: ... // seconds
* revalidate: ... // seconds
* expire: ... // seconds
* ```
*
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
*
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
*/
export function cacheLife(profile: {
/**
* This cache may be stale on clients for ... seconds before checking with the server.
*/
stale?: number,
/**
* If the server receives a new request after ... seconds, start revalidating new values in the background.
*/
revalidate?: number,
/**
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
*/
expire?: number
}): void
import { cacheTag } from 'next/dist/server/use-cache/cache-tag'
export { cacheTag }
export const unstable_cacheTag: typeof cacheTag
export const unstable_cacheLife: typeof cacheLife
}

88
.next/types/routes.d.ts vendored Normal file
View File

@ -0,0 +1,88 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = "/" | "/about" | "/docs" | "/homepage" | "/network" | "/network/api" | "/network/rooms" | "/notify" | "/pricing" | "/pricing/enterprise" | "/pricing/faq" | "/search" | "/skills" | "/skills/docs" | "/skills/publish" | "/solutions" | "/solutions/governance" | "/solutions/memory" | "/solutions/rooms"
type PageRoutes = never
type LayoutRoutes = "/homepage" | "/notify" | "/project" | "/project/repo" | "/repository" | "/repository/settings" | "/settings" | "/workspace"
type RedirectRoutes = never
type RewriteRoutes = never
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
interface ParamMap {
"/": {}
"/about": {}
"/docs": {}
"/homepage": {}
"/network": {}
"/network/api": {}
"/network/rooms": {}
"/notify": {}
"/pricing": {}
"/pricing/enterprise": {}
"/pricing/faq": {}
"/project": {}
"/project/repo": {}
"/repository": {}
"/repository/settings": {}
"/search": {}
"/settings": {}
"/skills": {}
"/skills/docs": {}
"/skills/publish": {}
"/solutions": {}
"/solutions/governance": {}
"/solutions/memory": {}
"/solutions/rooms": {}
"/workspace": {}
}
export type ParamsOf<Route extends Routes> = ParamMap[Route]
interface LayoutSlotMap {
"/homepage": never
"/notify": never
"/project": never
"/project/repo": never
"/repository": never
"/repository/settings": never
"/settings": never
"/workspace": never
}
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
declare global {
/**
* Props for Next.js App Router page components
* @example
* ```tsx
* export default function Page(props: PageProps<'/blog/[slug]'>) {
* const { slug } = await props.params
* return <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}

286
.next/types/validator.ts Normal file
View File

@ -0,0 +1,286 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
// This file validates that all pages and layouts export the correct types
import type { AppRoutes, LayoutRoutes, ParamMap } from "./routes.js"
import type { ResolvingMetadata, ResolvingViewport } from "next/types.js"
type AppPageConfig<Route extends AppRoutes = AppRoutes> = {
default: React.ComponentType<{ params: Promise<ParamMap[Route]> } & any> | ((props: { params: Promise<ParamMap[Route]> } & any) => React.ReactNode | Promise<React.ReactNode> | never | void | Promise<void>)
generateStaticParams?: (props: { params: ParamMap[Route] }) => Promise<any[]> | any[]
generateMetadata?: (
props: { params: Promise<ParamMap[Route]> } & any,
parent: ResolvingMetadata
) => Promise<any> | any
generateViewport?: (
props: { params: Promise<ParamMap[Route]> } & any,
parent: ResolvingViewport
) => Promise<any> | any
metadata?: any
viewport?: any
}
type LayoutConfig<Route extends LayoutRoutes = LayoutRoutes> = {
default: React.ComponentType<LayoutProps<Route>> | ((props: LayoutProps<Route>) => React.ReactNode | Promise<React.ReactNode> | never | void | Promise<void>)
generateStaticParams?: (props: { params: ParamMap[Route] }) => Promise<any[]> | any[]
generateMetadata?: (
props: { params: Promise<ParamMap[Route]> } & any,
parent: ResolvingMetadata
) => Promise<any> | any
generateViewport?: (
props: { params: Promise<ParamMap[Route]> } & any,
parent: ResolvingViewport
) => Promise<any> | any
metadata?: any
viewport?: any
}
// Validate ../../src/app/about/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/about">> = Specific
const handler = {} as typeof import("../../src/app/about/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/docs/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/docs">> = Specific
const handler = {} as typeof import("../../src/app/docs/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/homepage/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/homepage">> = Specific
const handler = {} as typeof import("../../src/app/homepage/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/network/api/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/network/api">> = Specific
const handler = {} as typeof import("../../src/app/network/api/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/network/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/network">> = Specific
const handler = {} as typeof import("../../src/app/network/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/network/rooms/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/network/rooms">> = Specific
const handler = {} as typeof import("../../src/app/network/rooms/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/notify/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/notify">> = Specific
const handler = {} as typeof import("../../src/app/notify/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/">> = Specific
const handler = {} as typeof import("../../src/app/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/pricing/enterprise/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/pricing/enterprise">> = Specific
const handler = {} as typeof import("../../src/app/pricing/enterprise/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/pricing/faq/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/pricing/faq">> = Specific
const handler = {} as typeof import("../../src/app/pricing/faq/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/pricing/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/pricing">> = Specific
const handler = {} as typeof import("../../src/app/pricing/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/search/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/search">> = Specific
const handler = {} as typeof import("../../src/app/search/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/skills/docs/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/skills/docs">> = Specific
const handler = {} as typeof import("../../src/app/skills/docs/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/skills/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/skills">> = Specific
const handler = {} as typeof import("../../src/app/skills/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/skills/publish/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/skills/publish">> = Specific
const handler = {} as typeof import("../../src/app/skills/publish/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/solutions/governance/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/solutions/governance">> = Specific
const handler = {} as typeof import("../../src/app/solutions/governance/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/solutions/memory/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/solutions/memory">> = Specific
const handler = {} as typeof import("../../src/app/solutions/memory/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/solutions/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/solutions">> = Specific
const handler = {} as typeof import("../../src/app/solutions/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/solutions/rooms/page.tsx
{
type __IsExpected<Specific extends AppPageConfig<"/solutions/rooms">> = Specific
const handler = {} as typeof import("../../src/app/solutions/rooms/page.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/homepage/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/homepage">> = Specific
const handler = {} as typeof import("../../src/app/homepage/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/notify/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/notify">> = Specific
const handler = {} as typeof import("../../src/app/notify/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/project/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/project">> = Specific
const handler = {} as typeof import("../../src/app/project/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/project/repo/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/project/repo">> = Specific
const handler = {} as typeof import("../../src/app/project/repo/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/repository/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/repository">> = Specific
const handler = {} as typeof import("../../src/app/repository/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/repository/settings/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/repository/settings">> = Specific
const handler = {} as typeof import("../../src/app/repository/settings/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/settings/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/settings">> = Specific
const handler = {} as typeof import("../../src/app/settings/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}
// Validate ../../src/app/workspace/layout.tsx
{
type __IsExpected<Specific extends LayoutConfig<"/workspace">> = Specific
const handler = {} as typeof import("../../src/app/workspace/layout.js")
type __Check = __IsExpected<typeof handler>
// @ts-ignore
type __Unused = __Check
}

View File

@ -14,6 +14,7 @@ metadata:
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/enable-websocket: "true"
spec:
ingressClassName: nginx
tls:

View File

@ -2,6 +2,7 @@ use crate::error::RoomError;
use crate::service::RoomService;
use crate::ws_context::WsUserContext;
use chrono::Utc;
use models::agents::model as ai_model;
use models::rooms::room_ai;
use sea_orm::*;
use uuid::Uuid;
@ -20,10 +21,20 @@ impl RoomService {
.all(&self.db)
.await?;
Ok(models
.into_iter()
.map(super::RoomAiResponse::from)
.collect())
let mut responses = Vec::with_capacity(models.len());
for model in models {
let model_name = ai_model::Entity::find_by_id(model.model)
.one(&self.db)
.await
.ok()
.flatten()
.map(|m| m.name);
let mut resp = super::RoomAiResponse::from(model);
resp.model_name = model_name;
responses.push(resp);
}
Ok(responses)
}
pub async fn room_ai_upsert(
@ -40,7 +51,7 @@ impl RoomService {
.one(&self.db)
.await?;
let model = if let Some(existing) = existing {
let saved = if let Some(existing) = existing {
let mut active: room_ai::ActiveModel = existing.into();
if request.version.is_some() {
active.version = Set(request.version);
@ -93,7 +104,16 @@ impl RoomService {
.await?
};
Ok(super::RoomAiResponse::from(model))
let model_name = ai_model::Entity::find_by_id(saved.model)
.one(&self.db)
.await
.ok()
.flatten()
.map(|m| m.name);
let mut resp = super::RoomAiResponse::from(saved);
resp.model_name = model_name;
Ok(resp)
}
pub async fn room_ai_delete(

View File

@ -109,6 +109,7 @@ impl From<room_ai::Model> for super::RoomAiResponse {
Self {
room: value.room,
model: value.model,
model_name: None,
version: value.version,
call_count: value.call_count,
last_call_at: value.last_call_at,

View File

@ -24,10 +24,11 @@ use models::agent_task::AgentType;
const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
/// Legacy: <user>uuid</user> or <user>username</user>
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
/// Matches <mention type="..." id="...">label</mention>
/// Legacy: <mention type="..." id="...">label</mention>
static MENTION_TAG_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| {
regex_lite::Regex::new(
@ -36,6 +37,13 @@ static MENTION_TAG_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
.unwrap()
});
/// New format: @[type:id:label]
/// e.g. @[user:550e8400-e29b-41d4-a716-446655440000:alice]
/// @[repo:660e8400-e29b-41d4-a716-446655440001:my-repo]
/// @[ai:gpt-4o:Claude]
static MENTION_BRACKET_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"@\[([a-z]+):([^:\]]+):([^\]]+)\]").unwrap());
#[derive(Clone)]
pub struct RoomService {
pub db: AppDatabase,
@ -542,8 +550,10 @@ impl RoomService {
Ok(())
}
/// Extracts user UUIDs from both the legacy `<user>uuid</user>` format
/// and the new `<mention type="user" id="uuid">label</mention>` format.
/// Extracts user UUIDs from all mention formats:
/// - Legacy: `<user>uuid</user>`
/// - Legacy: `<mention type="user" id="uuid">label</mention>`
/// - New: `@[user:uuid:label]`
pub fn extract_mentions(content: &str) -> Vec<Uuid> {
let mut mentioned = Vec::new();
@ -559,7 +569,7 @@ impl RoomService {
}
}
// New <mention type="user" id="...">label</mention> format
// Legacy <mention type="user" id="...">label</mention> format
for cap in MENTION_TAG_RE.captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "user" {
@ -572,11 +582,26 @@ impl RoomService {
}
}
// New @[user:uuid:label] format
for cap in MENTION_BRACKET_RE.captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "user" {
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
if !mentioned.contains(&uuid) {
mentioned.push(uuid);
}
}
}
}
}
mentioned
}
/// Resolves user mentions from both the legacy `<user>...` format and the
/// new `<mention type="user" id="uuid">label</mention>` format.
/// Resolves user mentions from all formats:
/// - Legacy: `<user>uuid</user>` or `<user>username</user>`
/// - Legacy: `<mention type="user" id="uuid">label</mention>`
/// - New: `@[user:uuid:label]`
/// Repository and AI mention types are accepted but produce no user UUIDs.
pub async fn resolve_mentions(&self, content: &str) -> Vec<Uuid> {
use models::users::User;
@ -657,6 +682,45 @@ impl RoomService {
}
}
// New @[user:uuid:label] format
for cap in MENTION_BRACKET_RE.captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "user" {
let id = id_m.as_str().trim();
if let Ok(uuid) = Uuid::parse_str(id) {
if !resolved.contains(&uuid) {
resolved.push(uuid);
}
} else {
// Fall back to label-based username lookup
if let Some(label_m) = cap.get(3) {
let label = label_m.as_str().trim();
if !label.is_empty() {
let label_lower = label.to_lowercase();
if seen_usernames.contains(&label_lower) {
continue;
}
seen_usernames.push(label_lower.clone());
if let Some(user) = User::find()
.filter(models::users::user::Column::Username.eq(label_lower))
.one(&self.db)
.await
.ok()
.flatten()
{
if !resolved.contains(&user.uid) {
resolved.push(user.uid);
}
}
}
}
}
}
// `repository` and `ai` mention types: no user UUIDs
}
}
resolved
}

View File

@ -260,6 +260,7 @@ pub struct RoomAiUpsertRequest {
pub struct RoomAiResponse {
pub room: Uuid,
pub model: Uuid,
pub model_name: Option<String>,
pub version: Option<Uuid>,
pub call_count: i64,
pub last_call_at: Option<DateTime<Utc>>,

View File

@ -37970,6 +37970,10 @@
"type": "string",
"format": "uuid"
},
"model_name": {
"type": "string",
"nullable": true
},
"version": {
"type": [
"string",

View File

@ -22,6 +22,12 @@
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.0",
"@tanstack/react-virtual": "^3.13.23",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-mention": "^3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@tiptap/suggestion": "^3.22.3",
"axios": "^1.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -48,10 +54,11 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tippy.js": "^6.3.7",
"tw-animate-css": "^1.4.0",
"uuid": "^13.0.0",
"zustand": "^5.0.0",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

649
pnpm-lock.yaml generated
View File

@ -35,6 +35,24 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tiptap/core':
specifier: ^3.22.3
version: 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-mention':
specifier: ^3.22.3
version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@tiptap/suggestion@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-placeholder':
specifier: ^3.22.3
version: 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/react':
specifier: ^3.22.3
version: 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tiptap/starter-kit':
specifier: ^3.22.3
version: 3.22.3
'@tiptap/suggestion':
specifier: ^3.22.3
version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
axios:
specifier: ^1.7.0
version: 1.14.0
@ -113,6 +131,9 @@ importers:
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
tippy.js:
specifier: ^6.3.7
version: 6.3.7
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@ -594,6 +615,9 @@ packages:
'@oxc-project/types@0.122.0':
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@ -798,6 +822,9 @@ packages:
react-redux:
optional: true
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rolldown/binding-android-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1141,6 +1168,173 @@ packages:
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@tiptap/core@3.22.3':
resolution: {integrity: sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==}
peerDependencies:
'@tiptap/pm': ^3.22.3
'@tiptap/extension-blockquote@3.22.3':
resolution: {integrity: sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-bold@3.22.3':
resolution: {integrity: sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-bubble-menu@3.22.3':
resolution: {integrity: sha512-Y6zQjh0ypDg32HWgICEvmPSKjGLr39k3aDxxt/H0uQEZSfw4smT0hxUyyyjVjx68C6t6MTnwdfz0hPI5lL68vQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-bullet-list@3.22.3':
resolution: {integrity: sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==}
peerDependencies:
'@tiptap/extension-list': ^3.22.3
'@tiptap/extension-code-block@3.22.3':
resolution: {integrity: sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-code@3.22.3':
resolution: {integrity: sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-document@3.22.3':
resolution: {integrity: sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-dropcursor@3.22.3':
resolution: {integrity: sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==}
peerDependencies:
'@tiptap/extensions': ^3.22.3
'@tiptap/extension-floating-menu@3.22.3':
resolution: {integrity: sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-gapcursor@3.22.3':
resolution: {integrity: sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==}
peerDependencies:
'@tiptap/extensions': ^3.22.3
'@tiptap/extension-hard-break@3.22.3':
resolution: {integrity: sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-heading@3.22.3':
resolution: {integrity: sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-horizontal-rule@3.22.3':
resolution: {integrity: sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-italic@3.22.3':
resolution: {integrity: sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-link@3.22.3':
resolution: {integrity: sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-list-item@3.22.3':
resolution: {integrity: sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==}
peerDependencies:
'@tiptap/extension-list': ^3.22.3
'@tiptap/extension-list-keymap@3.22.3':
resolution: {integrity: sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==}
peerDependencies:
'@tiptap/extension-list': ^3.22.3
'@tiptap/extension-list@3.22.3':
resolution: {integrity: sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/extension-mention@3.22.3':
resolution: {integrity: sha512-wJmpjU6WqZgbMJUwGQKhwnzCdN/DtsFGRsExCvncuQxFKgsMzhW+NWwmzgrGJDyS8BMKzqwyKlSc1dcMOYzgJQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/suggestion': ^3.22.3
'@tiptap/extension-ordered-list@3.22.3':
resolution: {integrity: sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==}
peerDependencies:
'@tiptap/extension-list': ^3.22.3
'@tiptap/extension-paragraph@3.22.3':
resolution: {integrity: sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-placeholder@3.22.3':
resolution: {integrity: sha512-7vbtlDVO00odqCnsMSmA4b6wjL5PFdfExFsdsDO0K0VemqHZ/doIRx/tosNUD1VYSOyKQd8U7efUjkFyVoIPlg==}
peerDependencies:
'@tiptap/extensions': ^3.22.3
'@tiptap/extension-strike@3.22.3':
resolution: {integrity: sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-text@3.22.3':
resolution: {integrity: sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extension-underline@3.22.3':
resolution: {integrity: sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/extensions@3.22.3':
resolution: {integrity: sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@tiptap/pm@3.22.3':
resolution: {integrity: sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==}
'@tiptap/react@3.22.3':
resolution: {integrity: sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.22.3':
resolution: {integrity: sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==}
'@tiptap/suggestion@3.22.3':
resolution: {integrity: sha512-m2c+5gDj2vW7UI1J4JHCKehQUVE12qBhgF+DC+WEWUU8ZrFNf5OEYWQHDNsopa5RRpilfKfhPNbMtXgvGOsk6g==}
peerDependencies:
'@tiptap/core': ^3.22.3
'@tiptap/pm': ^3.22.3
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@ -1189,9 +1383,18 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@ -1570,6 +1773,9 @@ packages:
typescript:
optional: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -1758,6 +1964,10 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@ -1906,6 +2116,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@ -2430,6 +2644,12 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -2455,6 +2675,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@ -2507,6 +2731,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@ -2758,6 +2985,9 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
@ -2860,6 +3090,64 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
prosemirror-changeset@2.4.1:
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.4:
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
prosemirror-menu@1.3.2:
resolution: {integrity: sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
prosemirror-view@1.41.8:
resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -2868,6 +3156,10 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -3061,6 +3353,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@ -3252,6 +3547,9 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tldts-core@7.0.27:
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
@ -3320,6 +3618,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@ -3463,6 +3764,9 @@ packages:
yaml:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@ -4088,6 +4392,8 @@ snapshots:
'@oxc-project/types@0.122.0': {}
'@popperjs/core@2.11.8': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
@ -4259,6 +4565,8 @@ snapshots:
react: 19.2.4
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
'@remirror/core-constants@3.0.0': {}
'@rolldown/binding-android-arm64@1.0.0-rc.12':
optional: true
@ -4501,6 +4809,198 @@ snapshots:
'@tanstack/virtual-core@3.13.23': {}
'@tiptap/core@3.22.3(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/pm': 3.22.3
'@tiptap/extension-blockquote@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-bold@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-bubble-menu@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
optional: true
'@tiptap/extension-bullet-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/extension-code@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-document@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-dropcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-floating-menu@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
optional: true
'@tiptap/extension-gapcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-hard-break@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-heading@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-horizontal-rule@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/extension-italic@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-link@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-list-keymap@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/extension-mention@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@tiptap/suggestion@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/suggestion': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-ordered-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-paragraph@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-placeholder@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-strike@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-text@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-underline@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/pm@3.22.3':
dependencies:
prosemirror-changeset: 2.4.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.4
prosemirror-menu: 1.3.2
prosemirror-model: 1.25.4
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
'@tiptap/react@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-floating-menu': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.22.3':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/extension-blockquote': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-bold': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-bullet-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-code': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-code-block': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-document': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-dropcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-gapcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-hard-break': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-heading': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-horizontal-rule': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-italic': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-link': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/extension-list-item': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-list-keymap': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-ordered-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
'@tiptap/extension-paragraph': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-strike': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-text': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extension-underline': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
'@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@tiptap/suggestion@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
dependencies:
'@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
'@tiptap/pm': 3.22.3
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@ -4552,10 +5052,19 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@2.0.0': {}
'@types/ms@2.1.0': {}
'@types/node@24.12.0':
@ -4936,6 +5445,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -5079,6 +5590,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.2
entities@4.5.0: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
@ -5280,6 +5793,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -5722,6 +6237,12 @@ snapshots:
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -5747,6 +6268,15 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {}
@ -5904,6 +6434,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdurl@2.0.0: {}
media-typer@1.1.0: {}
merge-descriptors@2.0.0: {}
@ -6267,6 +6799,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
orderedmap@2.1.1: {}
outvariant@1.4.3: {}
p-limit@3.1.0:
@ -6358,6 +6892,109 @@ snapshots:
property-information@7.1.0: {}
prosemirror-changeset@2.4.1:
dependencies:
prosemirror-transform: 1.12.0
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.4:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.1
prosemirror-model: 1.25.4
prosemirror-menu@1.3.2:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.5.0
prosemirror-state: 1.4.4
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.8:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -6365,6 +7002,8 @@ snapshots:
proxy-from-env@2.1.0: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {}
qs@6.15.0:
@ -6603,6 +7242,8 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
rope-sequence@1.3.4: {}
router@2.2.0:
dependencies:
debug: 4.4.3
@ -6839,6 +7480,10 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
tldts-core@7.0.27: {}
tldts@7.0.27:
@ -6905,6 +7550,8 @@ snapshots:
typescript@5.9.3: {}
uc.micro@2.1.0: {}
undici-types@7.16.0: {}
unicorn-magic@0.3.0: {}
@ -7036,6 +7683,8 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
w3c-keyname@2.2.8: {}
web-streams-polyfill@3.3.3: {}
which@2.0.2:

View File

@ -6,8 +6,8 @@ import { Loader2 } from 'lucide-react';
import { CreateRoomDialog } from '@/components/room/CreateRoomDialog';
import { DeleteRoomAlert } from '@/components/room/DeleteRoomAlert';
import { EditRoomDialog } from '@/components/room/EditRoomDialog';
import { RoomChatPanel } from '@/components/room/RoomChatPanel';
import { RoomList } from '@/components/room/RoomList';
import { DiscordChannelSidebar } from '@/components/room/DiscordChannelSidebar';
import { DiscordChatPanel } from '@/components/room/DiscordChatPanel';
function ProjectRoomInner() {
const params = useParams();
@ -104,27 +104,30 @@ function ProjectRoomInner() {
}
return (
<div className="flex h-full min-w-0 flex-1 overflow-hidden">
<RoomList
projectDisplayName={projectName}
<div className="discord-layout">
{/* Channel sidebar */}
<DiscordChannelSidebar
projectName={projectName}
rooms={rooms}
selectedRoomId={activeRoomId}
onSelectRoom={handleSelectRoom}
onCreateRoom={handleOpenCreate}
/>
{/* Main chat area */}
{activeRoom ? (
<RoomChatPanel
<DiscordChatPanel
room={activeRoom}
isAdmin={isAdmin}
onClose={handleClose}
onDelete={() => setDeleteDialogOpen(true)}
/>
) : (
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-8 text-center">
<p className="text-sm font-medium text-foreground">Select a channel</p>
<p className="mt-1 text-xs text-muted-foreground">
<div className="flex flex-1 items-center justify-center bg-background">
<div className="text-center px-6 py-8">
<div className="text-5xl mb-4 text-foreground/20">#</div>
<p className="text-lg font-medium text-foreground/60">Select a channel</p>
<p className="text-sm mt-1 text-muted-foreground/60">
Choose a channel from the sidebar to start chatting.
</p>
</div>

View File

@ -1455,6 +1455,7 @@ export type ApiResponseRoomAiResponse = {
data?: {
room: string;
model: string;
model_name?: string | null;
version?: string | null;
call_count: number;
last_call_at?: string | null;
@ -2111,6 +2112,7 @@ export type ApiResponseVecRoomAiResponse = {
data?: Array<{
room: string;
model: string;
model_name?: string | null;
version?: string | null;
call_count: number;
last_call_at?: string | null;
@ -4329,6 +4331,7 @@ export type ReviewerInfo = {
export type RoomAiResponse = {
room: string;
model: string;
model_name?: string;
version?: string | null;
call_count: number;
last_call_at?: string | null;

View File

@ -0,0 +1,198 @@
'use client';
import {memo, useCallback, useState} from 'react';
import type {RoomWithCategory} from '@/contexts/room-context';
import {Button} from '@/components/ui/button';
import {cn} from '@/lib/utils';
import {ChevronDown, ChevronRight, Hash, Lock, Plus, Settings} from 'lucide-react';
interface RoomWithUnread extends RoomWithCategory {
unread_count?: number;
}
interface DiscordChannelSidebarProps {
projectName: string;
rooms: RoomWithCategory[];
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
onCreateRoom: () => void;
onOpenSettings?: () => void;
}
interface ChannelGroupProps {
categoryName: string;
rooms: RoomWithCategory[];
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
isCollapsed?: boolean;
onToggle?: () => void;
}
const ChannelGroup = memo(function ChannelGroup({
categoryName,
rooms,
selectedRoomId,
onSelectRoom,
isCollapsed,
onToggle,
}: ChannelGroupProps) {
if (rooms.length === 0) return null;
return (
<div className="discord-channel-category">
<button
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
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>
{!isCollapsed && (
<ul className="space-y-0.5 pl-2">
{rooms.map((room) => {
const isSelected = selectedRoomId === room.id;
const unreadCount = (room as RoomWithUnread).unread_count ?? 0;
return (
<li key={room.id}>
<button
type="button"
onClick={() => onSelectRoom(room)}
className={cn('discord-channel-item w-full', isSelected && 'active')}
>
<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"/>
)}
{unreadCount > 0 && (
<span className="discord-mention-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
});
export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
projectName,
rooms,
selectedRoomId,
onSelectRoom,
onCreateRoom,
onOpenSettings,
}: DiscordChannelSidebarProps) {
// Group rooms by category
const uncategorized = rooms.filter((r) => !r.category_info?.name);
const categorized = rooms.filter((r) => r.category_info?.name);
// Build category map: categoryName → rooms[]
const categoryMap = new Map<string, RoomWithCategory[]>();
for (const room of categorized) {
const name = room.category_info!.name;
if (!categoryMap.has(name)) categoryMap.set(name, []);
categoryMap.get(name)!.push(room);
}
// Collapse state per category
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const toggleCategory = useCallback((name: string) => {
setCollapsed((prev) => ({...prev, [name]: !prev[name]}));
}, []);
// Uncategorized channels at top, then alphabetical categories
const sortedCategoryNames = Array.from(categoryMap.keys()).sort();
return (
<div className="discord-channel-sidebar">
{/* Header */}
<div className="discord-channel-header">
<div
className="discord-channel-header-title flex items-center gap-2 font-bold"
style={{color: 'var(--room-text)'}}
>
<span>{projectName}</span>
</div>
<div className="flex items-center gap-1">
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{color: 'var(--room-text-muted)'}}
title="Channel settings"
>
<Settings className="h-4 w-4"/>
</button>
)}
</div>
</div>
{/* Channel list */}
<div className="discord-channel-list">
{/* Uncategorized */}
{uncategorized.length > 0 && (
<ChannelGroup
categoryName="Channels"
rooms={uncategorized}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
/>
)}
{/* Categorized */}
{sortedCategoryNames.map((catName) => (
<ChannelGroup
key={catName}
categoryName={catName}
rooms={categoryMap.get(catName)!}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
isCollapsed={collapsed[catName]}
onToggle={() => toggleCategory(catName)}
/>
))}
{rooms.length === 0 && (
<div className="px-4 py-8 text-center">
<p className="text-sm mb-3" style={{color: 'var(--room-text-muted)'}}>No channels yet</p>
<Button
size="sm"
onClick={onCreateRoom}
className="bg-primary hover:bg-primary/90 text-primary-foreground border-none"
>
<Plus className="mr-1 h-3.5 w-3.5"/>
Create Channel
</Button>
</div>
)}
</div>
{/* Footer: user / add channel */}
<div
className="border-t px-2 py-2"
style={{borderColor: 'var(--room-border)', background: 'var(--room-sidebar)'}}
>
<button
onClick={onCreateRoom}
className="discord-add-channel-btn w-full"
>
<Plus className="h-4 w-4"/>
<span>Add Channel</span>
</button>
</div>
</div>
);
});

View File

@ -0,0 +1,360 @@
'use client';
/**
* Discord-style main chat panel.
* Layout: header + message list + input + member sidebar
*/
import type { RoomResponse, RoomThreadResponse } from '@/client';
import type { MessageWithMeta } from '@/contexts';
import { cn } from '@/lib/utils';
import {
Hash, Lock, Users, Search, ChevronLeft,
AtSign, Pin, Settings,
} from 'lucide-react';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { MessageList } from './message/MessageList';
import { MessageInput, type MessageInputHandle } from './message/MessageInput';
import { RoomMessageEditDialog } from './RoomMessageEditDialog';
import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { DiscordMemberList } from './DiscordMemberList';
import { useRoom } from '@/contexts';
// ─── Main Panel ──────────────────────────────────────────────────────────
interface DiscordChatPanelProps {
room: RoomResponse;
isAdmin: boolean;
onClose: () => void;
onDelete: () => void;
}
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordChatPanelProps) {
const {
messages,
members,
membersLoading,
sendMessage,
editMessage,
revokeMessage,
updateRoom,
wsStatus,
wsError,
wsClient,
threads,
refreshThreads,
roomAiConfigs,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageInputRef = useRef<MessageInputHandle | null>(null);
const [replyingTo, setReplyingTo] = useState<MessageWithMeta | null>(null);
const [editingMessage, setEditingMessage] = useState<MessageWithMeta | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editHistoryDialogOpen, setEditHistoryDialogOpen] = useState(false);
const [selectedMessageForHistory, setSelectedMessageForHistory] = useState<string>('');
const [showSettings, setShowSettings] = useState(false);
const [showMentions, setShowMentions] = useState(false);
const [showMemberList, setShowMemberList] = useState(true);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
const wsDotClass =
wsStatus === 'open' ? 'connected'
: wsStatus === 'connecting' ? 'connecting'
: 'disconnected';
const handleSend = useCallback(
(content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null);
},
[sendMessage, replyingTo],
);
const handleEdit = useCallback((message: MessageWithMeta) => {
setEditingMessage(message);
setEditDialogOpen(true);
}, []);
const handleViewEditHistory = useCallback((message: MessageWithMeta) => {
setSelectedMessageForHistory(message.id);
setEditHistoryDialogOpen(true);
}, []);
const handleEditConfirm = useCallback(
(newContent: string) => {
if (!editingMessage) return;
editMessage(editingMessage.id, newContent);
setEditDialogOpen(false);
setEditingMessage(null);
toast.success('Message updated');
},
[editingMessage?.id, editMessage],
);
const handleRevoke = useCallback(
(message: MessageWithMeta) => {
revokeMessage(message.id);
toast.success('Message deleted');
},
[revokeMessage],
);
const handleOpenThread = useCallback((message: MessageWithMeta) => {
if (!message.thread_id) return;
const thread = threads.find(t => t.id === message.thread_id);
if (thread) setActiveThread({ thread, parentMessage: message });
}, [threads]);
const handleCreateThread = useCallback(async (message: MessageWithMeta) => {
if (!wsClient || message.thread_id) return;
try {
const thread = await wsClient.threadCreate(room.id, message.seq);
setActiveThread({ thread, parentMessage: message });
refreshThreads();
} catch (err) {
console.error('Failed to create thread:', err);
toast.error('Failed to create thread');
}
}, [wsClient, room.id, refreshThreads]);
const handleUpdateRoom = useCallback(
async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true);
try {
await updateRoom(room.id, name, isPublic);
toast.success('Room updated');
setShowSettings(false);
} catch {
toast.error('Failed to update room');
} finally {
setIsUpdatingRoom(false);
}
},
[room.id, updateRoom],
);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
useEffect(() => {
setReplyingTo(null);
setEditingMessage(null);
setEditDialogOpen(false);
setShowSettings(false);
setShowMentions(false);
setActiveThread(null);
}, [room.id]);
return (
<div
className="flex flex-1 min-w-0 flex-col"
style={{ background: 'var(--room-bg)', color: 'var(--room-text)' }}
>
{/* ── Header ─────────────────────────────────────────────── */}
<header
className="flex h-12 items-center border-b px-4 gap-2 shrink-0"
style={{ borderColor: 'var(--room-border)', background: 'var(--room-sidebar)' }}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{room.public
? <Hash className="h-5 w-5 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
: <Lock className="h-4 w-4 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
}
<h1 className="text-base font-semibold truncate" style={{ color: 'var(--room-text)' }}>{room.room_name}</h1>
<div className="discord-ws-status ml-1">
<span className={cn('discord-ws-dot', wsDotClass)} />
{wsStatus !== 'open' && wsStatus !== 'idle' && (
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>
{wsStatus === 'connecting' ? 'Connecting...' : wsError ?? 'Disconnected'}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showMentions ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showMentions ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowMentions(v => !v); setShowSettings(false); }}
title="@ Mentions"
>
<AtSign className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
title="Search messages"
>
<Search className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showMemberList ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showMemberList ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowMemberList(v => !v); setShowSettings(false); }}
title="Member list"
>
<Users className="h-4 w-4" />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
title="Pinned messages"
>
<Pin className="h-4 w-4" />
</button>
{/* Settings — opens Room Settings */}
{isAdmin && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: showSettings ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showSettings ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowSettings(v => !v); setShowMentions(false); setShowMemberList(false); }}
title="Room Settings"
>
<Settings className="h-4 w-4" />
</button>
)}
{isAdmin && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={onDelete}
title="Delete channel"
>
<span className="text-xs">🗑</span>
</button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 md:hidden"
style={{ color: 'var(--room-text-muted)' }}
onClick={onClose}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
</div>
</header>
{/* ── Body ──────────────────────────────────────────────── */}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
roomId={room.id}
messages={messages}
messagesEndRef={messagesEndRef}
onInlineEdit={handleEdit}
onViewHistory={handleViewEditHistory}
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={undefined}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
<MessageInput
ref={messageInputRef}
roomName={room.room_name ?? 'room'}
onSend={handleSend}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
/>
</div>
{showMemberList && (
<DiscordMemberList
members={members}
membersLoading={membersLoading}
onMemberClick={() => {}}
aiConfigs={roomAiConfigs}
/>
)}
</div>
{/* ── Slide Panels ──────────────────────────────────────── */}
{showSettings && (
<aside
className="absolute right-0 top-12 bottom-0 w-[360px] border-l z-20 flex flex-col animate-in slide-in-from-right duration-200"
style={{
borderColor: 'var(--room-border)',
background: 'var(--room-bg)',
}}
>
<RoomSettingsPanel
room={room}
onUpdate={handleUpdateRoom}
onClose={() => setShowSettings(false)}
isPending={isUpdatingRoom}
/>
</aside>
)}
{showMentions && (
<aside
className="absolute right-0 top-12 bottom-0 w-[380px] border-l z-20 flex flex-col animate-in slide-in-from-right duration-200"
style={{ borderColor: 'var(--room-border)', background: 'var(--room-bg)' }}
>
<RoomMentionPanel
onClose={() => setShowMentions(false)}
onSelectNotification={(mention) => {
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}}
/>
</aside>
)}
{activeThread && (
<RoomThreadPanel
roomId={room.id}
thread={activeThread.thread}
parentMessage={activeThread.parentMessage}
onClose={() => setActiveThread(null)}
/>
)}
<RoomMessageEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
originalContent={editingMessage?.content ?? ''}
onConfirm={handleEditConfirm}
/>
<RoomMessageEditHistoryDialog
open={editHistoryDialogOpen}
onOpenChange={setEditHistoryDialogOpen}
messageId={selectedMessageForHistory}
roomId={room.id}
/>
</div>
);
}

View File

@ -0,0 +1,226 @@
'use client';
import React, { memo, useMemo } from 'react';
import type { RoomMemberResponse } from '@/client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2, Shield, UserRound, Bot } from 'lucide-react';
import { useTheme } from '@/contexts';
import { cn } from '@/lib/utils';
import type { RoomAiConfig } from '@/contexts';
interface DiscordMemberListProps {
members: RoomMemberResponse[];
membersLoading: boolean;
onMemberClick?: (member: RoomMemberResponse) => void;
onMemberMention?: (id: string, label: string) => void;
aiConfigs?: RoomAiConfig[];
}
// Role color mapping — AI Studio clean palette
const ROLE_COLORS: Record<string, string> = {
owner: '#1c7ded',
admin: '#1c7ded',
moderator: '#6b7280',
member: '#9ca3af',
guest: '#9ca3af',
};
function getRoleColor(role: string): string {
return ROLE_COLORS[role] ?? ROLE_COLORS['member'];
}
function MemberItem({
member,
onClick,
}: {
member: RoomMemberResponse;
onClick?: (member: RoomMemberResponse) => void;
}) {
const { resolvedTheme } = useTheme();
const displayName = (member.user_info as { username?: string } | null | undefined)?.username ?? member.user.slice(0, 8);
const roleColor = getRoleColor(member.role ?? 'member');
return (
<button
type="button"
className="discord-member-item w-full text-left"
onClick={() => onClick?.(member)}
title={`@${displayName}${member.role ?? 'member'}`}
>
<div className="discord-member-avatar-wrap">
<Avatar className="h-8 w-8">
{member.user_info?.avatar_url ? (
<AvatarImage src={member.user_info.avatar_url} alt={displayName} />
) : null}
<AvatarFallback
className="text-xs font-semibold"
style={{ background: `${roleColor}33`, color: roleColor }}
>
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="discord-member-status-dot online" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-medium"
style={{ color: roleColor }}
>
{displayName}
</span>
</div>
{member.role && member.role !== 'member' && (
<span className={cn('text-[10px] uppercase tracking-wide',
resolvedTheme === 'dark' ? 'opacity-60' : 'text-muted-foreground'
)}>
{member.role}
</span>
)}
</div>
</button>
);
}
function MemberSection({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div>
<div className="discord-member-section-title">
{icon}
<span>{title}</span>
</div>
<div>{children}</div>
</div>
);
}
export const DiscordMemberList = memo(function DiscordMemberList({
members,
membersLoading,
onMemberClick,
aiConfigs = [],
}: DiscordMemberListProps) {
useTheme(); // theme consumed via CSS variables
const { admins, moderators, regularMembers } = useMemo(() => {
const a: RoomMemberResponse[] = [];
const m: RoomMemberResponse[] = [];
const r: RoomMemberResponse[] = [];
for (const member of members) {
if (member.role === 'owner' || member.role === 'admin') a.push(member);
else if (member.role === 'moderator') m.push(member);
else r.push(member);
}
return { admins: a, moderators: m, regularMembers: r };
}, [members]);
return (
<div className="discord-member-sidebar">
<div className="discord-member-header">
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>Members</span>
<span className="ml-auto text-xs" style={{ color: 'var(--room-text-muted)' }}>{members.length}</span>
</div>
<div className="flex-1 overflow-y-auto py-2 px-1">
{membersLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
</div>
) : (
<>
{/* AI section — always at top */}
{aiConfigs.length > 0 && (
<MemberSection
title={`AI — ${aiConfigs.length}`}
icon={<Bot className="h-3 w-3" />}
>
{aiConfigs.map((ai) => {
const label = ai.modelName ?? ai.model;
return (
<button
key={ai.model}
type="button"
className="discord-member-item w-full text-left"
onClick={() => onMemberClick?.({
user: ai.model,
user_info: { username: label },
role: 'ai',
} as RoomMemberResponse)}
title={`@${label}`}
>
<div className="discord-member-avatar-wrap">
<Avatar className="h-8 w-8">
<AvatarFallback
className="text-xs font-semibold"
style={{ background: '#10b98133', color: '#10b981' }}
>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="discord-member-status-dot online" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-medium"
style={{ color: '#10b981' }}
>
{label}
</span>
<span
className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium"
style={{ background: 'rgba(16,185,129,0.1)', color: '#10b981' }}
>
AI
</span>
</div>
</div>
</button>
);
})}
</MemberSection>
)}
{(admins.length > 0 || moderators.length > 0) && (
<MemberSection
title={`Online — ${admins.length + moderators.length}`}
icon={<Shield className="h-3 w-3" />}
>
{[...admins, ...moderators].map((member) => (
<MemberItem
key={member.user}
member={member}
onClick={onMemberClick}
/>
))}
</MemberSection>
)}
{regularMembers.length > 0 && (
<MemberSection
title={`Members — ${regularMembers.length}`}
icon={<UserRound className="h-3 w-3" />}
>
{regularMembers.map((member) => (
<MemberItem
key={member.user}
member={member}
onClick={onMemberClick}
/>
))}
</MemberSection>
)}
</>
)}
</div>
</div>
);
});

View File

@ -1,247 +0,0 @@
import { parse } from '@/lib/mention-ast';
import { forwardRef, useCallback, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
interface MentionInputProps {
/** Plain text value (contains <mention ... > tags for existing mentions) */
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
/** Callback fired when Ctrl+Enter is pressed */
onSend?: () => void;
}
/** CSS classes for rendered mention buttons */
const MENTION_STYLES: Record<string, string> = {
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5 mx-0.5',
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5 mx-0.5',
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5 mx-0.5',
};
const ICON_MAP: Record<string, string> = {
user: '👤', repository: '📦', ai: '🤖',
};
const LABEL_MAP: Record<string, string> = {
user: 'User', repository: 'Repo', ai: 'AI',
};
/** Escape HTML special characters */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Render plain text or mention nodes into HTML string for innerHTML */
function renderToHtml(text: string): string {
const nodes = parse(text);
if (nodes.length === 0 || (nodes.length === 1 && nodes[0].type === 'text')) {
// No structured mentions — just escape and return
return escapeHtml(text).replace(/\n/g, '<br>');
}
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml((node as { type: 'text'; text: string }).text).replace(/\n/g, '<br>');
} else if (node.type === 'mention') {
const m = node as { type: 'mention'; mentionType: string; id: string; label: string };
const style = MENTION_STYLES[m.mentionType] ?? MENTION_STYLES.user;
const icon = ICON_MAP[m.mentionType] ?? '🏷';
const label = LABEL_MAP[m.mentionType] ?? 'Mention';
html += `<span class="${style}" data-mention-type="${escapeHtml(m.mentionType)}" data-mention-id="${escapeHtml(m.id)}">`;
html += `<span>${icon}</span><strong>${escapeHtml(label)}:</strong> ${escapeHtml(m.label)}</span>`;
}
// ai_action nodes are treated as text for now
}
return html;
}
/** Extract plain text from a contenteditable div. Mentions contribute their visible label text. */
function getPlainText(container: HTMLElement): string {
let text = '';
for (const child of Array.from(container.childNodes)) {
if (child.nodeType === 3 /* TEXT_NODE */) {
text += child.textContent ?? '';
} else if (child.nodeType === 1 /* ELEMENT_NODE */) {
const el = child as HTMLElement;
if (el.tagName === 'BR') text += '\n';
else if (el.tagName === 'DIV' || el.tagName === 'P') text += getPlainText(el) + '\n';
else if (el.getAttribute('data-mention-type')) {
// Mention span — extract its displayed text (label), not the type/icon prefix
const raw = el.textContent ?? '';
// Format is "👤 User: username" — we want just "username"
const colonIdx = raw.lastIndexOf(':');
text += colonIdx >= 0 ? raw.slice(colonIdx + 1).trim() : raw;
}
else text += el.textContent ?? '';
}
}
return text;
}
/** Get character offset of selection within a contenteditable div */
function getCaretOffset(container: HTMLElement): number {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length;
}
/** Set caret at a given character offset within a contenteditable div */
function setCaretAtOffset(container: HTMLElement, targetOffset: number) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let count = 0;
let found = false;
function walk(node: Node) {
if (found) return;
if (node.nodeType === 3 /* TEXT_NODE */) {
const t = node.textContent ?? '';
if (count + t.length >= targetOffset) {
range.setStart(node, Math.min(targetOffset - count, t.length));
range.collapse(true);
found = true;
return;
}
count += t.length;
} else if (node.nodeType === 1 /* ELEMENT_NODE */) {
const el = node as HTMLElement;
if (el.getAttribute('data-mention-type')) {
// Non-editable mention span counts as one character unit
if (count + 1 > targetOffset) {
range.selectNodeContents(el);
range.collapse(false);
found = true;
return;
}
count += 1;
} else {
for (let i = 0; i < el.childNodes.length; i++) {
walk(el.childNodes[i] as Node);
if (found) return;
}
}
}
}
walk(container);
if (!found) {
range.selectNodeContents(container);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
value,
onChange,
placeholder,
disabled,
onSend,
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const internalValueRef = useRef(value);
// Merge forwarded ref with internal ref
useEffect(() => {
if (ref) {
if (typeof ref === 'function') ref(containerRef.current);
else (ref as React.RefObject<HTMLDivElement | null>).current = containerRef.current;
}
}, [ref]);
/** Track whether the last DOM update was from user input (vs programmatic) */
const isUserInputRef = useRef(true);
/** Sync external value changes into the DOM */
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// If the DOM already contains what we want, nothing to do.
if (getPlainText(el) === value) {
isUserInputRef.current = false;
return;
}
// Save cursor position ratio for non-user-input updates
const oldCaret = isUserInputRef.current ? getCaretOffset(el) : 0;
const oldLen = internalValueRef.current.length;
// Block selectionchange handler from reading wrong cursor position during DOM update
(window as any).__mentionBlockSelection = true;
// Update DOM
el.innerHTML = value.trim() ? renderToHtml(value) : '';
internalValueRef.current = value;
isUserInputRef.current = false;
// Restore caret for non-mention user updates
if (oldLen > 0) {
const ratio = oldCaret / oldLen;
const newLen = value.length;
setCaretAtOffset(el, Math.round(ratio * newLen));
}
// Unblock selectionchange after DOM has settled
requestAnimationFrame(() => {
(window as any).__mentionBlockSelection = false;
});
}, [value]);
/** Handle input changes — extracts plain text from DOM and sends to parent */
const handleInput = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const newText = getPlainText(el);
internalValueRef.current = newText;
isUserInputRef.current = true;
onChange(newText);
}, [onChange]);
/** Handle keyboard shortcuts */
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
// Ctrl+Enter → send message
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSend?.();
return;
}
// Plain Enter → swallow (Shift+Enter inserts newline via default behavior)
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
}
}, [onSend]);
/** Handle paste — insert plain text only */
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
}, []);
return (
<div
ref={containerRef}
contentEditable={!disabled}
suppressContentEditableWarning
className={cn(
'min-h-[80px] w-full resize-none overflow-y-auto rounded-md border border-input bg-background px-3 py-2',
'text-sm text-foreground outline-none',
'whitespace-pre-wrap break-words leading-6',
disabled && 'opacity-50 cursor-not-allowed select-none',
!value && 'empty',
)}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-placeholder={placeholder}
/>
);
});

View File

@ -1,514 +0,0 @@
import { buildMentionHtml, type Node } from '@/lib/mention-ast';
import type { MentionSuggestion, MentionType, RoomAiConfig } from '@/lib/mention-types';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Bot, ChevronRight, Database, SearchX, Sparkles, User, Check,
} from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
// ─── Types ───────────────────────────────────────────────────────────────────
interface PopoverPosition { top: number; left: number }
interface MentionPopoverProps {
members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>;
repos: Array<{ uid: string; repo_name: string }> | null;
aiConfigs: RoomAiConfig[];
reposLoading: boolean;
aiConfigsLoading: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Plain text value of the input */
inputValue: string;
/** Current caret offset in the text */
cursorPosition: number;
onSelect: (newValue: string, newCursorPos: number) => void;
onOpenChange: (open: boolean) => void;
onCategoryEnter: (category: string) => void;
suggestions: MentionSuggestion[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
}
// ─── Category Configs ────────────────────────────────────────────────────────
const CATEGORY_CONFIG: Record<string, { icon: JSX.Element; color: string; bgColor: string; borderColor: string; gradient: string }> = {
repository: {
icon: <Database className="h-3.5 w-3.5" />, color: 'text-violet-600 dark:text-violet-400',
bgColor: 'bg-violet-500/10 dark:bg-violet-500/15', borderColor: 'border-violet-500/20',
gradient: 'from-violet-500/5 to-transparent',
},
user: {
icon: <User className="h-3.5 w-3.5" />, color: 'text-sky-600 dark:text-sky-400',
bgColor: 'bg-sky-500/10 dark:bg-sky-500/15', borderColor: 'border-sky-500/20',
gradient: 'from-sky-500/5 to-transparent',
},
ai: {
icon: <Sparkles className="h-3.5 w-3.5" />, color: 'text-emerald-600 dark:text-emerald-400',
bgColor: 'bg-emerald-500/10 dark:bg-emerald-500/15', borderColor: 'border-emerald-500/20',
gradient: 'from-emerald-500/5 to-transparent',
},
};
const CATEGORIES: MentionSuggestion[] = [
{ type: 'category', category: 'repository', label: 'Repository' },
{ type: 'category', category: 'user', label: 'User' },
{ type: 'category', category: 'ai', label: 'AI' },
];
// ─── Utilities ───────────────────────────────────────────────────────────────
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function HighlightMatch({ text, match }: { text: string; match: string }) {
if (!match) return <>{text}</>;
const parts = text.split(new RegExp(`(${escapeRegExp(match)})`, 'gi'));
return (
<>
{parts.map((part, i) =>
/[@a-z]/i.test(part) && new RegExp(escapeRegExp(match), 'gi').test(part) ? (
<span key={i} className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 font-semibold rounded px-0.5">
{part}
</span>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
function CategoryHeader({ suggestion, isSelected }: { suggestion: MentionSuggestion; isSelected: boolean }) {
const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null;
return (
<div className={cn(
'flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-muted-foreground transition-colors duration-150',
isSelected && 'text-foreground bg-muted/50',
)}>
<div className={cn('flex h-5 w-5 items-center justify-center rounded-md border', config?.bgColor, config?.borderColor, config?.color)}>
{config?.icon ?? <ChevronRight className="h-3 w-3" />}
</div>
<span className="flex-1">{suggestion.label}</span>
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
</div>
);
}
function SuggestionItem({ suggestion, isSelected, onSelect, onMouseEnter, searchTerm }: {
suggestion: MentionSuggestion; isSelected: boolean;
onSelect: () => void; onMouseEnter: () => void; searchTerm: string;
}) {
const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null;
return (
<div className={cn(
'group relative flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-all duration-150 ease-out border-l-2 border-transparent',
isSelected && ['bg-gradient-to-r', config?.gradient ?? 'from-muted to-transparent', 'border-l-current', config?.color ?? 'text-foreground'],
!isSelected && 'hover:bg-muted/40',
)}
onPointerDown={(e) => e.preventDefault()}
onClick={onSelect} onMouseEnter={onMouseEnter}
>
{/* Icon */}
<div className="relative shrink-0">
{suggestion.category === 'ai' ? (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/20 shadow-sm">
<Bot className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
) : suggestion.category === 'repository' ? (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/20 to-violet-600/10 border border-violet-500/20 shadow-sm">
<Database className="h-4 w-4 text-violet-600 dark:text-violet-400" />
</div>
) : suggestion.avatar ? (
<Avatar size="sm" className="h-8 w-8 ring-2 ring-background">
<AvatarImage src={suggestion.avatar} alt={suggestion.label} />
<AvatarFallback className="bg-sky-500/10 text-sky-600 text-xs font-medium">
{suggestion.label.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500/20 to-sky-600/10 border border-sky-500/20 shadow-sm">
<span className="text-xs font-semibold text-sky-600 dark:text-sky-400">
{suggestion.label[0]?.toUpperCase()}
</span>
</div>
)}
{/* Selection dot */}
<div className={cn(
'absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full bg-primary border-2 border-background transition-transform duration-150',
isSelected ? 'scale-100' : 'scale-0',
)} />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="block truncate text-sm font-medium">
<HighlightMatch text={suggestion.label} match={searchTerm} />
</span>
{suggestion.category && (
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 border', config?.bgColor, config?.color, config?.borderColor)}>
{suggestion.category}
</span>
)}
</div>
{suggestion.sublabel && (
<span className="block truncate text-xs text-muted-foreground mt-0.5">{suggestion.sublabel}</span>
)}
</div>
{/* Check mark */}
<div className={cn(
'flex items-center justify-center w-5 h-5 rounded-full transition-all duration-150',
isSelected ? 'bg-primary/10 text-primary' : 'opacity-0 group-hover:opacity-30',
)}>
<Check className="h-3.5 w-3.5" />
</div>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="px-3 py-3 space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-3 py-1">
<Skeleton className="h-8 w-8 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5"><Skeleton className="h-3.5 w-24" /><Skeleton className="h-3 w-16" /></div>
</div>
))}
</div>
);
}
function EmptyState({ loading }: { loading?: boolean }) {
return (
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-xl mb-3 bg-muted/80')}>
{loading ? (
<div className="h-5 w-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
) : (
<SearchX className="h-5 w-5 text-muted-foreground" />
)}
</div>
<p className="text-sm font-medium text-foreground">{loading ? 'Loading...' : 'No matches found'}</p>
<p className="text-xs text-muted-foreground mt-1">{loading ? 'Please wait' : 'Try a different search term'}</p>
</div>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function MentionPopover({
members, repos, aiConfigs, reposLoading, aiConfigsLoading,
containerRef, inputValue, cursorPosition, onSelect, onOpenChange, onCategoryEnter,
suggestions, selectedIndex, setSelectedIndex,
}: MentionPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<(HTMLDivElement | null)[]>([]);
// Stable refs for event listener closure
const mentionStateRef = useRef<{ category: string; item: string; hasColon: boolean } | null>(null);
const visibleSuggestionsRef = useRef<MentionSuggestion[]>([]);
const selectedIndexRef = useRef(selectedIndex);
const handleSelectRef = useRef<() => void>(() => {});
const closePopoverRef = useRef<() => void>(() => {});
const onCategoryEnterRef = useRef(onCategoryEnter);
onCategoryEnterRef.current = onCategoryEnter;
// Parse mention state
const mentionState = useMemo(() => {
const before = inputValue.slice(0, cursorPosition);
const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!match) return null;
return { category: match[1].toLowerCase(), item: (match[3] ?? '').toLowerCase(), hasColon: match[2] !== undefined };
}, [inputValue, cursorPosition]);
mentionStateRef.current = mentionState;
visibleSuggestionsRef.current = suggestions;
selectedIndexRef.current = selectedIndex;
// Auto-select first item
useEffect(() => { setSelectedIndex(0); }, [suggestions.length]);
// Scroll selected into view
useLayoutEffect(() => {
const el = itemsRef.current[selectedIndex];
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [selectedIndex]);
const closePopover = useCallback(() => onOpenChange(false), [onOpenChange]);
closePopoverRef.current = closePopover;
// ─── Insertion Logic ───────────────────────────────────────────────────────
const doInsert = useCallback((suggestion: MentionSuggestion) => {
const textarea = containerRef.current;
if (!textarea || suggestion.type !== 'item') return;
// Get current DOM text and cursor position
const textBeforeCursor = (() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return inputValue.slice(0, cursorPosition);
const range = sel.getRangeAt(0);
const pre = range.cloneRange();
pre.selectNodeContents(textarea);
pre.setEnd(range.startContainer, range.startOffset);
let count = 0;
const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode as Text;
if (node === range.startContainer) { count += range.startOffset; break; }
count += node.length;
}
return inputValue.slice(0, count);
})();
const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!atMatch) return;
const [fullPattern] = atMatch;
const startPos = textBeforeCursor.length - fullPattern.length;
const before = textBeforeCursor.substring(0, startPos);
// Reconstruct after: from DOM text after cursor
const domText = (() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return inputValue.substring(cursorPosition);
const range = sel.getRangeAt(0);
const post = range.cloneRange();
post.setStart(range.endContainer, range.endOffset);
let count = 0;
const tree = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT);
while (tree.nextNode()) count += (tree.currentNode as Text).length;
return inputValue.substring(cursorPosition);
})();
const html = buildMentionHtml(
suggestion.category!, suggestion.mentionId!, suggestion.label,
);
const spacer = ' ';
const newValue = before + html + spacer + domText;
const newCursorPos = startPos + html.length + spacer.length;
onSelect(newValue, newCursorPos);
closePopover();
}, [containerRef, inputValue, cursorPosition, onSelect, closePopover]);
handleSelectRef.current = doInsert;
// ─── Positioning ───────────────────────────────────────────────────────────
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
useLayoutEffect(() => {
if (!containerRef.current) return;
if (!mentionState) return;
const textarea = containerRef.current;
const styles = window.getComputedStyle(textarea);
// Measure @pattern position using hidden clone div
const tempDiv = document.createElement('span');
tempDiv.style.cssText = getComputedStyleStyles(styles);
tempDiv.style.position = 'absolute';
tempDiv.style.visibility = 'hidden';
tempDiv.style.whiteSpace = 'pre-wrap';
tempDiv.style.pointerEvents = 'none';
document.body.appendChild(tempDiv);
const pattern = mentionState.hasColon ? `@${mentionState.category}:${mentionState.item}` : `@${mentionState.category}`;
tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern)));
const span = document.createElement('span');
span.textContent = '|';
tempDiv.appendChild(span);
const rect = textarea.getBoundingClientRect();
const borderTop = parseFloat(styles.borderTopWidth) || 0;
const borderLeft = parseFloat(styles.borderLeftWidth) || 0;
const scrollTop = textarea.scrollTop;
const scrollLeft = textarea.scrollLeft;
const lineHeight = parseFloat(styles.lineHeight) || 20;
const contentTop = rect.top + borderTop + span.offsetTop - scrollTop;
const contentLeft = rect.left + borderLeft + span.offsetLeft - scrollLeft;
// Append clone for measurement
const clone = textarea.cloneNode(true) as HTMLDivElement;
clone.style.cssText = getComputedStyleStyles(styles);
clone.style.position = 'fixed';
clone.style.top = '-9999px';
clone.style.left = '-9999px';
clone.style.width = styles.width;
clone.style.maxWidth = styles.maxWidth;
clone.innerHTML = '';
const textSpan = document.createElement('span');
textSpan.style.whiteSpace = 'pre';
textSpan.textContent = inputValue.slice(0, cursorPosition);
clone.appendChild(textSpan);
const cursorSpan = document.createElement('span');
cursorSpan.textContent = '|';
clone.appendChild(cursorSpan);
document.body.appendChild(clone);
document.body.removeChild(clone);
const popoverWidth = 320;
const popoverHeight = Math.min(360, suggestions.length * 56 + 100);
const vp = 16;
const left = Math.max(Math.max(contentLeft, vp), window.innerWidth - popoverWidth - vp);
const shouldBelow = contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp;
const top = shouldBelow
? contentTop + lineHeight + 8
: Math.max(vp, contentTop - popoverHeight - 8);
setPosition({ top, left: contentLeft });
document.body.removeChild(tempDiv);
// Simple approach: just use the textarea bounds + estimated offset
// This is sufficient for most cases
}, [inputValue, cursorPosition, containerRef, mentionState, suggestions.length]);
// ─── Keyboard Navigation ───────────────────────────────────────────────────
useEffect(() => {
const textarea = containerRef.current;
if (!textarea) return;
const onKeyDown = (e: KeyboardEvent) => {
const vis = visibleSuggestionsRef.current;
if (vis.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = (selectedIndexRef.current + 1) % Math.max(vis.length, 1);
setSelectedIndex(next);
selectedIndexRef.current = next;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1);
setSelectedIndex(prev);
selectedIndexRef.current = prev;
} else if (e.key === 'Enter' || e.key === 'Tab') {
const item = vis[selectedIndexRef.current];
if (!item) return;
e.preventDefault();
if (item.type === 'category') onCategoryEnterRef.current(item.category!);
else handleSelectRef.current(item);
} else if (e.key === 'Escape') {
e.preventDefault();
closePopoverRef.current();
}
};
textarea.addEventListener('keydown', onKeyDown);
return () => textarea.removeEventListener('keydown', onKeyDown);
}, [containerRef, setSelectedIndex]);
// ─── Close on Outside Click ────────────────────────────────────────────────
useEffect(() => {
const handler = (e: PointerEvent) => {
const t = e.target as Node;
if (popoverRef.current?.contains(t)) return;
if (containerRef.current?.contains(t)) return;
closePopover();
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [closePopover, containerRef]);
// Hide when mention state is gone
useEffect(() => {
if (!mentionState) closePopover();
}, [inputValue, cursorPosition, closePopover, mentionState]);
// Don't render if no valid mention context
if (!mentionState) return null;
const isLoading = (mentionState.category === 'repository' && reposLoading) ||
(mentionState.category === 'ai' && aiConfigsLoading);
const currentCategory = mentionState.hasColon ? mentionState.category : null;
const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null;
return (
<div ref={popoverRef} className="animate-in fade-in zoom-in-95 duration-150 ease-out fixed z-50 w-80 overflow-hidden rounded-xl border border-border/50 bg-popover/95 backdrop-blur-xl shadow-2xl shadow-black/10 dark:shadow-black/20 ring-1 ring-black/5 dark:ring-white/10" style={{ top: position.top, left: position.left }}>
{/* Header */}
<div className={cn('flex items-center gap-2 px-3 py-2.5 border-b border-border/50 bg-gradient-to-r from-muted/50 to-transparent', catConfig?.gradient)}>
<div className={cn('flex h-6 w-6 items-center justify-center rounded-md bg-muted border border-border/50', catConfig?.bgColor, catConfig?.borderColor)}>
<span className="text-xs font-semibold text-muted-foreground">@</span>
</div>
<div className="flex-1 flex items-center gap-1.5 min-w-0">
{mentionState.hasColon ? (
<>
<span className={cn('text-xs font-medium px-1.5 py-0.5 rounded-md', catConfig?.bgColor, catConfig?.color)}>{mentionState.category}</span>
{mentionState.item && (<>
<span className="text-muted-foreground">/</span>
<span className="text-xs text-foreground font-medium truncate">{mentionState.item}</span>
</>)}
</>
) : (
<span className="text-xs text-muted-foreground">Type to filter</span>
)}
</div>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono"></kbd>
<span>navigate</span>
<span className="text-muted-foreground/50">|</span>
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono"></kbd>
<span>select</span>
</div>
</div>
{/* Content */}
{suggestions.length > 0 ? (
<ScrollArea className="max-h-80">
<div className="py-1">
{suggestions.map((s, i) => s.type === 'category' ? (
<div key={s.label}>
<CategoryHeader suggestion={s} isSelected={i === selectedIndex} />
</div>
) : (
<div key={s.mentionId || s.label} ref={el => { itemsRef.current[i] = el; }}>
<SuggestionItem
suggestion={s} isSelected={i === selectedIndex}
onSelect={() => doInsert(s)}
onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }}
searchTerm={mentionState.item}
/>
</div>
))}
</div>
</ScrollArea>
) : isLoading ? <LoadingSkeleton /> : <EmptyState loading={isLoading} />}
{/* Footer */}
{suggestions[selectedIndex]?.type === 'item' && (
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50 bg-muted/30">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className={cn('px-1.5 py-0.5 rounded-md font-medium', catConfig?.bgColor, catConfig?.color)}>
{suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].category : ''}
</span>
<span className="truncate max-w-[140px]">{suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].label : ''}</span>
</div>
</div>
)}
</div>
);
}
/** Extract relevant CSS properties from computed styles for measurement clone */
function getComputedStyleStyles(styles: CSSStyleDeclaration): string {
const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingLeft', 'paddingRight',
'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth',
'boxSizing', 'width', 'maxWidth', 'letterSpacing', 'tabSize'];
return props.map(p => `${p.replace(/([A-Z])/g, '-$1').toLowerCase()}:${styles.getPropertyValue(p)}`).join(';');
}

View File

@ -1,284 +0,0 @@
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
import type { RoomAiConfig, ResolveMentionName } from '@/lib/mention-types';
import { memo, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
interface LegacyMentionToken {
full: string;
type: MentionType;
name: string;
}
function isMentionNameChar(ch: string): boolean {
return /[A-Za-z0-9._:\/-]/.test(ch);
}
/**
* Parses legacy mention formats for backward compatibility with old messages:
* - `<user>name</user>` (old backend format)
* - `@type:name` (old colon format)
* - `<type>name</type>` (old XML format)
*/
function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
const tokens: LegacyMentionToken[] = [];
let cursor = 0;
while (cursor < text.length) {
const angleOpen = text.indexOf('<', cursor);
const atOpen = text.indexOf('@', cursor);
let next: number;
let style: 'angle' | 'at';
if (angleOpen >= 0 && (atOpen < 0 || angleOpen <= atOpen)) {
next = angleOpen;
style = 'angle';
} else if (atOpen >= 0) {
next = atOpen;
style = 'at';
} else {
break;
}
const typeStart = next + 1;
const colon = text.indexOf(':', typeStart);
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
if (colon >= 0 && colon - typeStart <= 20) {
const typeRaw = text.slice(typeStart, colon).toLowerCase();
if (!validTypes.includes(typeRaw as MentionType)) {
cursor = next + 1;
continue;
}
let end = colon + 1;
while (end < text.length && isMentionNameChar(text[end])) {
end++;
}
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
if (name) {
tokens.push({ full: text.slice(next, closeBracket), type: typeRaw as MentionType, name });
cursor = closeBracket;
continue;
}
}
if (style === 'angle') {
let typeEnd = typeStart;
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
typeEnd++;
}
const typeCandidate = text.slice(typeStart, typeEnd);
if (validTypes.includes(typeCandidate as MentionType)) {
const closeTag = `</${typeCandidate}>`;
const contentStart = typeEnd;
const tagClose = text.indexOf(closeTag, cursor);
if (tagClose >= 0) {
const name = text.slice(contentStart, tagClose);
const fullMatch = text.slice(next, tagClose + closeTag.length);
tokens.push({ full: fullMatch, type: typeCandidate as MentionType, name });
cursor = tagClose + closeTag.length;
continue;
}
}
}
cursor = next + 1;
}
return tokens;
}
function extractFirstMentionName(text: string, type: MentionType): string | null {
const token = extractLegacyMentionTokens(text).find((item) => item.type === type);
return token?.name ?? null;
}
interface MessageContentWithMentionsProps {
content: string;
/** Members list for resolving user mention IDs to display names */
members?: RoomMemberResponse[];
/** Repository list for resolving repository mention IDs to display names */
repos?: ProjectRepositoryItem[];
/** AI configs for resolving AI mention IDs to display names */
aiConfigs?: RoomAiConfig[];
}
const mentionStyles: Record<string, string> = {
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors text-sm leading-5',
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium cursor-pointer hover:bg-purple-200 dark:hover:bg-purple-900/60 transition-colors text-sm leading-5',
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium cursor-pointer hover:bg-green-200 dark:hover:bg-green-900/60 transition-colors text-sm leading-5',
notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium cursor-pointer hover:bg-yellow-200 dark:hover:bg-yellow-900/60 transition-colors text-sm leading-5',
};
function renderNode(
node: Node,
index: number,
resolveName: (type: string, id: string, label: string) => string,
): React.ReactNode {
if (node.type === 'text') {
return <span key={index}>{node.text}</span>;
}
if (node.type === 'mention') {
const displayName = resolveName(node.mentionType, node.id, node.label);
const iconMap: Record<string, string> = {
ai: '🤖',
user: '👤',
repository: '📦',
notify: '🔔',
};
const labelMap: Record<string, string> = {
ai: 'AI',
user: 'User',
repository: 'Repo',
notify: 'Notify',
};
const icon = iconMap[node.mentionType] ?? '🏷';
const label = labelMap[node.mentionType] ?? 'Mention';
return (
<button
key={index}
type="button"
className={cn(
mentionStyles[node.mentionType] ?? mentionStyles.user,
'inline-flex items-center gap-1 cursor-pointer border-0 bg-transparent p-0',
'hover:opacity-80 transition-opacity',
)}
onClick={() => {
document.dispatchEvent(
new CustomEvent('mention-click', {
detail: {
type: node.mentionType,
id: node.id,
label: displayName,
},
bubbles: true,
}),
);
}}
>
<span className="text-sm">{icon}</span>
<span>{label}:</span>
<span className="font-medium">{displayName}</span>
</button>
);
}
if (node.type === 'ai_action') {
return (
<span
key={index}
className="inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-mono text-xs"
>
/{node.action}
{node.args ? ` ${node.args}` : ''}
</span>
);
}
return null;
}
/** Renders message content with @mention highlighting using styled spans.
* Supports the new `<mention type="..." id="...">label</mention>` format
* and legacy formats for backward compatibility with old messages.
*/
export const MessageContentWithMentions = memo(function MessageContentWithMentions({
content,
members = [],
repos = [],
aiConfigs = [],
}: MessageContentWithMentionsProps) {
const nodes = useMemo(() => {
// Try the new AST parser first (handles <mention> and <ai> tags)
const ast = parse(content);
if (ast.length > 0) return ast;
// Fall back to legacy parser for old-format messages
const legacy = extractLegacyMentionTokens(content);
if (legacy.length === 0) return [{ type: 'text' as const, text: content }];
const parts: Node[] = [];
let cursor = 0;
for (const token of legacy) {
const idx = content.indexOf(token.full, cursor);
if (idx === -1) continue;
if (idx > cursor) {
parts.push({ type: 'text', text: content.slice(cursor, idx) });
}
parts.push({
type: 'mention',
mentionType: token.type as MentionMentionType,
id: '',
label: token.name,
});
cursor = idx + token.full.length;
}
if (cursor < content.length) {
parts.push({ type: 'text', text: content.slice(cursor) });
}
return parts;
}, [content]);
// Resolve ID → display name for each mention type
const resolveName = useCallback(
(type: string, id: string, label: string): string => {
if (type === 'user') {
const member = members.find((m) => m.user === id);
return member?.user_info?.username ?? member?.user ?? label;
}
if (type === 'repository') {
const repo = repos.find((r) => r.uid === id);
return repo?.repo_name ?? label;
}
if (type === 'ai') {
const cfg = aiConfigs.find((c) => c.model === id);
return cfg?.modelName ?? cfg?.model ?? label;
}
return label;
},
[members, repos, aiConfigs],
);
return (
<div
className={cn(
'text-sm text-foreground',
'max-w-full min-w-0 break-words whitespace-pre-wrap',
'[&_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',
)}
>
{nodes.map((node, i) => renderNode(node, i, resolveName))}
</div>
);
});
/** Extract first mentioned user name from text */
export function extractMentionedUserUid(
text: string,
participants: Array<{ uid: string; name: string; is_ai: boolean }>,
): string | null {
const userName = extractFirstMentionName(text, 'user');
if (!userName) return null;
const user = participants.find((p) => !p.is_ai && p.name === userName);
return user ? user.uid : null;
}
/** Extract first mentioned AI name from text */
export function extractMentionedAiUid(
text: string,
participants: Array<{ uid: string; name: string; is_ai: boolean }>,
): string | null {
const aiName = extractFirstMentionName(text, 'ai');
if (!aiName) return null;
const ai = participants.find((p) => p.is_ai && p.name === aiName);
return ai ? ai.uid : null;
}

View File

@ -1,12 +1,8 @@
import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
import type { RoomResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
import { useRoom, type MessageWithMeta } from '@/contexts';
import { type RoomAiConfig } from '@/contexts/room-context';
import { useRoomDraft } from '@/hooks/useRoomDraft';
import { useMentionState } from '@/lib/mention-state';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { buildMentionHtml } from '@/lib/mention-ast';
import { MentionInput } from './MentionInput';
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
import {
memo,
@ -19,10 +15,9 @@ import {
} from 'react';
import { toast } from 'sonner';
import { RoomAiTasksPanel } from './RoomAiTasksPanel';
import { MentionPopover } from './MentionPopover';
import { MessageList } from './message';
import { RoomMessageEditDialog } from './RoomMessageEditDialog';
import { RoomMessageEditHistoryDialog } from './RoomMessageEditHistoryDialog';
import { RoomMessageList } from './RoomMessageList';
import { RoomParticipantsPanel } from './RoomParticipantsPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { RoomMessageSearch } from './RoomMessageSearch';
@ -32,20 +27,13 @@ import { RoomThreadPanel } from './RoomThreadPanel';
export interface ChatInputAreaHandle {
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
/** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */
insertCategory: (category: string) => void;
focus: () => void;
}
interface ChatInputAreaProps {
roomName: string;
onSend: (content: string) => void;
isSending: boolean;
members: RoomMemberResponse[];
repos?: ProjectRepositoryItem[];
reposLoading?: boolean;
aiConfigs?: RoomAiConfig[];
aiConfigsLoading?: boolean;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
draft: string;
@ -55,14 +43,8 @@ interface ChatInputAreaProps {
}
const ChatInputArea = memo(function ChatInputArea({
roomName,
onSend,
isSending,
members,
repos,
reposLoading,
aiConfigs,
aiConfigsLoading,
replyingTo,
onCancelReply,
draft,
@ -70,127 +52,14 @@ const ChatInputArea = memo(function ChatInputArea({
onClearDraft,
ref,
}: ChatInputAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
// ─── Central mention state ───────────────────────────────────────────────
const ms = useMentionState(
members,
repos ?? [],
aiConfigs,
!!reposLoading,
!!aiConfigsLoading,
);
// ─── Track DOM cursor offset → ms.cursorOffset on user navigation ──────
// Uses selectionchange event which fires when caret moves via arrows/clicks.
// Ignores programmatic DOM updates (blocked via window.__mentionBlockSelection).
const prevCursorRef = useRef(ms.cursorOffset);
const skipCursorTrackingRef = useRef(false);
useEffect(() => {
const readCursor = () => {
if (skipCursorTrackingRef.current) return;
if ((window as any).__mentionBlockSelection) return;
const el = containerRef.current;
if (!el) return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return;
const range = sel.getRangeAt(0);
// Build a range from start of contenteditable to caret
const pre = document.createRange();
pre.selectNodeContents(el);
pre.setEnd(sel.focusNode, sel.focusOffset);
let count = 0;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let found = false;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
if (node === sel.focusNode) { count += sel.focusOffset; found = true; break; }
count += node.length;
}
if (!found) return;
if (count !== prevCursorRef.current) {
prevCursorRef.current = count;
ms.setCursorOffset(count);
}
};
document.addEventListener('selectionchange', readCursor);
return () => document.removeEventListener('selectionchange', readCursor);
});
// ─── Imperative handle ──────────────────────────────────────────────────
const onDraftChangeRef = useRef(onDraftChange);
onDraftChangeRef.current = onDraftChange;
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
insertMention: (id: string, label: string) => {
const insertion = ms.buildInsertionAt('user', id, label);
const before = draft.substring(0, insertion.startPos);
const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos);
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
ms.setShowMentionPopover(false);
setTimeout(() => containerRef.current?.focus(), 0);
},
insertCategory: (category: string) => {
const textBefore = draft.substring(0, ms.cursorOffset);
const atMatch = textBefore.match(/@([^:@\s]*)(:([^\s]*))?$/);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = ms.cursorOffset - fullMatch.length;
const before = draft.substring(0, startPos);
const afterPartial = draft.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
skipCursorTrackingRef.current = true;
setTimeout(() => {
skipCursorTrackingRef.current = false;
ms.setCursorOffset(newCursorPos);
ms.setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(/@([^:@\s]*)(:([^\s]*))?$/));
}, 0);
focus: () => {
textareaRef.current?.focus();
},
}));
// ─── Mention select (from popover) ──────────────────────────────────────
const handleMentionSelect = useCallback(() => {
const suggestion = ms.suggestions[ms.selectedIndex];
if (!suggestion || suggestion.type !== 'item') return;
const insertion = ms.buildInsertionAt(suggestion.category, suggestion.mentionId, suggestion.label);
const before = draft.substring(0, insertion.startPos);
const newValue = before + insertion.html + ' ' + draft.substring(ms.cursorOffset);
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
ms.setShowMentionPopover(false);
setTimeout(() => containerRef.current?.focus(), 0);
}, [draft, ms]);
// ─── Auto-show/hide popover when @ context appears ────────────────────
useEffect(() => {
if (ms.mentionState && !ms.showMentionPopover) {
ms.setShowMentionPopover(true);
} else if (!ms.mentionState && ms.showMentionPopover) {
ms.setShowMentionPopover(false);
}
}, [ms.mentionState, ms.showMentionPopover, ms.setShowMentionPopover]);
// ─── mention-click handler (from message mentions) ─────────────────────
useEffect(() => {
const onMentionClick = (e: Event) => {
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail;
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
const spacer = ' ';
const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset);
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
setTimeout(() => containerRef.current?.focus(), 0);
};
document.addEventListener('mention-click', onMentionClick);
return () => document.removeEventListener('mention-click', onMentionClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="border-t border-border/70 bg-background p-3">
{replyingTo && (
@ -204,79 +73,40 @@ const ChatInputArea = memo(function ChatInputArea({
)}
<div className="relative">
<MentionInput
ref={containerRef}
value={ms.value}
onChange={(v) => {
onDraftChange(v);
ms.setValue(v);
}}
onSend={() => {
const content = ms.value.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
<textarea
ref={textareaRef}
className="w-full resize-none rounded-md border border-border/70 bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 min-h-[44px] max-h-[200px] overflow-y-auto pr-16"
placeholder="Message..."
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}
}}
placeholder={`Message #${roomName}...`}
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1">
<Button
size="sm"
onClick={() => {
const content = ms.value.trim();
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}}
disabled={isSending}
disabled={isSending || !draft.trim()}
className="h-7 px-3"
>
<Send className="mr-1 h-3 w-3" />
Send
</Button>
</div>
{ms.showMentionPopover && ms.mentionState && (
<MentionPopover
members={members}
repos={repos ?? null}
aiConfigs={aiConfigs ?? []}
reposLoading={!!reposLoading}
aiConfigsLoading={!!aiConfigsLoading}
containerRef={containerRef}
inputValue={ms.value}
cursorPosition={ms.cursorOffset}
onSelect={handleMentionSelect}
onOpenChange={ms.setShowMentionPopover}
onCategoryEnter={(category: string) => {
const textBefore = ms.value.substring(0, ms.cursorOffset);
const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = ms.cursorOffset - fullMatch.length;
const before = ms.value.substring(0, startPos);
const afterPartial = ms.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
// Defer cursor update until after DOM has flushed the new mention value.
// Skip tracking effect during the flush window to avoid it overwriting
// the deferred cursor with the old DOM position.
skipCursorTrackingRef.current = true;
setTimeout(() => {
skipCursorTrackingRef.current = false;
ms.setCursorOffset(newCursorPos);
}, 0);
}}
suggestions={ms.suggestions}
selectedIndex={ms.selectedIndex}
setSelectedIndex={ms.setSelectedIndex}
/>
)}
</div>
</div>
);
@ -346,10 +176,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
wsClient,
threads,
refreshThreads,
projectRepos,
reposLoading,
roomAiConfigs,
aiConfigsLoading,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -427,10 +253,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
[revokeMessage],
);
// Stable: chatInputRef is stable, no deps that change on message updates
const handleMention = useCallback((id: string, label: string) => {
chatInputRef.current?.insertMention(id, label, 'user');
}, []);
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
toast.info(`Selected message from ${message.send_at}`);
@ -616,7 +438,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
</header>
<div className="flex min-h-0 flex-1 flex-col">
<RoomMessageList
<MessageList
roomId={room.id}
messages={messages}
messagesEndRef={messagesEndRef}
@ -624,7 +446,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
onViewHistory={handleViewEditHistory}
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={handleMention}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
@ -635,11 +456,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
roomName={room.room_name ?? 'room'}
onSend={handleSend}
isSending={false}
members={members}
repos={projectRepos}
reposLoading={reposLoading}
aiConfigs={roomAiConfigs}
aiConfigsLoading={aiConfigsLoading}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
draft={draft}
@ -653,7 +469,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
<RoomParticipantsPanel
members={members}
membersLoading={membersLoading}
onMention={handleMention}
/>
</SlidePanel>

View File

@ -1,512 +0,0 @@
import type { MessageWithMeta } from '@/contexts';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
import { cn } from '@/lib/utils';
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { SmilePlus } from 'lucide-react';
import { useUser, useRoom } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge';
import { MessageContentWithMentions } from './MessageMentions';
import { ThreadIndicator } from './RoomThreadPanel';
import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from './sender';
const COMMON_EMOJIS = [
'👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🚀',
'✅', '⭐', '🔥', '💯', '👀', '🙏', '💪', '🤔',
];
interface RoomMessageBubbleProps {
message: MessageWithMeta;
roomId: string;
replyMessage?: MessageWithMeta | null;
grouped?: boolean;
showDate?: boolean;
onInlineEdit?: (message: MessageWithMeta, newContent: string) => void;
onViewHistory?: (message: MessageWithMeta) => void;
onRevoke?: (message: MessageWithMeta) => void;
onReply?: (message: MessageWithMeta) => void;
onMention?: (name: string, type: 'user' | 'ai') => void;
onOpenUserCard?: (payload: {
username: string;
displayName?: string | null;
avatarUrl?: string | null;
userId: string;
point: { x: number; y: number };
}) => void;
onOpenThread?: (message: MessageWithMeta) => void;
onCreateThread?: (message: MessageWithMeta) => void;
}
const TEXT_COLLAPSE_LINE_COUNT = 5;
function formatMessageTime(iso: string) {
const d = new Date(iso);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const isYesterday = new Date(now.getTime() - 86400000).toDateString() === d.toDateString();
if (isToday) {
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
}
return d.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export const RoomMessageBubble = memo(function RoomMessageBubble({
roomId,
message,
replyMessage,
grouped = false,
showDate = true,
onInlineEdit,
onViewHistory,
onRevoke,
onReply,
onMention,
onOpenUserCard,
onOpenThread,
onCreateThread,
}: RoomMessageBubbleProps) {
const [showFullText, setShowFullText] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system';
const displayName = getSenderDisplayName(message);
const senderModelId = getSenderModelId(message);
const avatarUrl = getAvatarFromUiMessage(message);
const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
const isStreaming = !!message.is_streaming;
const isEdited = !!message.edited_at;
const { user } = useUser();
const { wsClient, streamingMessages, members, projectRepos, roomAiConfigs } = useRoom();
const isOwner = user?.uid === getSenderUserUid(message);
const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true;
// True for messages that haven't been confirmed by the server yet.
// Handles both the old 'temp-' prefix and the new isOptimistic flag.
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
// Get streaming content if available
const displayContent = isStreaming && streamingMessages?.has(message.id)
? streamingMessages.get(message.id)!
: message.content;
// Detect narrow container width using CSS container query instead of ResizeObserver
// The .group/narrow class on the container enables CSS container query support
const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return;
try {
const existing = message.reactions?.find(r => r.emoji === emoji);
if (existing?.reacted_by_me) {
await wsClient.reactionRemove(roomId, message.id, emoji);
} else {
await wsClient.reactionAdd(roomId, message.id, emoji);
}
} catch (err) {
console.warn('[RoomMessage] Failed to update reaction:', err);
}
setShowReactionPicker(false);
}, [roomId, message.id, message.reactions, wsClient]);
const functionCalls = useMemo<FunctionCall[]>(
() =>
message.content_type === 'text' || message.content_type === 'Text'
? parseFunctionCalls(displayContent)
: [],
[displayContent, message.content_type],
);
const textContent = displayContent;
const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90));
}, 0);
const shouldCollapseText =
(message.content_type === 'text' || message.content_type === 'Text') &&
estimatedLines > TEXT_COLLAPSE_LINE_COUNT;
const isTextCollapsed = shouldCollapseText && !showFullText;
const handleAvatarClick = (event: React.MouseEvent<HTMLSpanElement>) => {
if (!onOpenUserCard || isAi || !isUserSender(message)) {
onMention?.(displayName, isAi ? 'ai' : 'user');
return;
}
if (message.sender_id) {
onOpenUserCard({
username: displayName,
avatarUrl: avatarUrl ?? null,
userId: message.sender_id,
point: { x: event.clientX, y: event.clientY },
});
}
};
// Inline edit handlers
const handleStartEdit = useCallback(() => {
setEditContent(message.content);
setIsEditing(true);
}, [message.content]);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditContent(message.content);
}, [message.content]);
const handleSaveEdit = useCallback(async () => {
if (!editContent.trim() || editContent === message.content) {
handleCancelEdit();
return;
}
setIsSavingEdit(true);
try {
if (onInlineEdit) {
onInlineEdit(message, editContent.trim());
}
setIsEditing(false);
} finally {
setIsSavingEdit(false);
}
}, [editContent, message, onInlineEdit, handleCancelEdit]);
return (
<div
ref={containerRef}
className={cn(
'group relative flex gap-3 px-4 transition-colors',
grouped ? 'py-0.5' : 'py-2',
!isSystem && 'hover:bg-muted/30',
isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5',
(isPending || isFailed) && 'opacity-60',
)}
>
{/* Avatar */}
{!grouped ? (
<Avatar
className={cn('size-8 shrink-0', onMention && 'cursor-pointer')}
onClick={handleAvatarClick}
>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName} /> : null}
<AvatarFallback className="bg-muted text-xs text-foreground">
{isAi ? <ModelIcon modelId={senderModelId} /> : initial}
</AvatarFallback>
</Avatar>
) : (
<div className="w-8 shrink-0" />
)}
{/* Message Content */}
<div className="min-w-0 flex-1">
{/* Header */}
{!grouped && (
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{displayName}</span>
{isSystem && (
<Badge variant="outline" className="gap-1 border-amber-500/40 text-[10px] text-amber-700">
<AlertTriangle className="size-3" />
System
</Badge>
)}
{showDate && (
<span className="text-xs text-muted-foreground">{formatMessageTime(message.send_at)}</span>
)}
{(isFailed || isPending) && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60" title={isFailed ? 'Send failed' : 'Sending...'}>
<AlertCircle className={cn('size-2.5', isFailed ? 'text-destructive' : 'animate-pulse')} />
{isFailed ? 'Failed' : 'Sending...'}
</span>
)}
</div>
)}
{/* Inline edit mode */}
{isEditing ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full min-h-[60px] resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
handleCancelEdit();
}
}}
/>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Press Ctrl+Enter to save, Esc to cancel
</span>
<Button
size="sm"
onClick={handleSaveEdit}
disabled={isSavingEdit || !editContent.trim()}
className="ml-auto h-7 px-3 text-xs"
>
{isSavingEdit ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
className="h-7 px-3 text-xs"
>
Cancel
</Button>
</div>
</div>
) : (
<>
{/* Revoked message indicator */}
{isRevoked ? (
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm italic text-muted-foreground">
<Trash2 className="size-3.5" />
<span>This message has been deleted</span>
</div>
) : (
<>
{/* Reply indicator */}
{replyMessage && (
<div className="mb-2 flex items-start gap-1.5 border-l-2 border-border pl-2 text-xs text-muted-foreground">
<ReplyIcon className="mt-0.5 size-3 shrink-0" />
<span className="truncate">
<span className="font-medium">{getSenderDisplayName(replyMessage)}</span>
<span className="ml-1">
{replyMessage.content_type === 'text' || replyMessage.content_type === 'Text'
? replyMessage.content.length > 64
? `${replyMessage.content.slice(0, 64)}...`
: replyMessage.content
: `[${replyMessage.content_type}]`}
</span>
</span>
</div>
)}
{/* Message content */}
<div
className="min-w-0 break-words text-sm text-foreground"
title={grouped ? formatMessageTime(message.send_at) : undefined}
>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<>
<div className={cn('relative', isTextCollapsed && 'max-h-[5.25rem] overflow-hidden')}>
{functionCalls.length > 0 ? (
functionCalls.map((call, index) => (
<div key={index} className="my-1 rounded-md border border-border/70 bg-muted/20 p-2">
<FunctionCallBadge functionCall={call} className="w-auto" />
</div>
))
) : (
<div className="max-w-full min-w-0 overflow-hidden whitespace-pre-wrap break-words">
<MessageContentWithMentions
content={displayContent}
members={members}
repos={projectRepos}
aiConfigs={roomAiConfigs}
/>
</div>
)}
{isStreaming && (
<span className="ml-0.5 inline-block size-1 animate-pulse align-middle rounded-full bg-primary" />
)}
{isTextCollapsed && (
<div className="pointer-events-none absolute inset-x-0 -bottom-1 h-14 bg-gradient-to-b from-transparent via-background/35 to-background/95" />
)}
</div>
{shouldCollapseText && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowFullText((v) => !v)}
className="mt-1 h-auto gap-1 p-0 text-xs text-muted-foreground hover:text-foreground"
>
{showFullText ? (
<><ChevronUp className="size-3" /> Show less</>
) : (
<><ChevronDown className="size-3" /> Show more</>
)}
</Button>
)}
</>
) : (
<span className="text-muted-foreground">[{message.content_type}]</span>
)}
</div>
{/* Thread indicator */}
{message.thread_id && onOpenThread && (
<ThreadIndicator
threadId={message.thread_id}
onClick={() => onOpenThread(message)}
/>
)}
{/* Edited indicator - bottom right corner */}
{isEdited && (
<div className="mt-1 flex items-center justify-end gap-0.5 text-[10px] text-muted-foreground/60">
<Edit2 className="size-2.5" />
<span>Edited</span>
</div>
)}
</>
)}
</>
)}
</div>
{/* Reaction badges - OUTSIDE message content div so they align with action toolbar */}
{(message.reactions?.length ?? 0) > 0 && (
<div className="flex flex-wrap items-center gap-1">
{message.reactions!.map((r) => (
<Button
key={r.emoji}
variant="ghost"
size="sm"
onClick={() => handleReaction(r.emoji)}
className={cn(
'h-6 gap-1 rounded-full px-2 text-xs transition-colors',
r.reacted_by_me
? 'border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
)}
>
<span>{r.emoji}</span>
<span className="font-medium">{r.count}</span>
</Button>
))}
</div>
)}
{/* Action toolbar - inline icon buttons */}
{!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* Add reaction */}
<Popover open={showReactionPicker} onOpenChange={setShowReactionPicker}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
}
/>
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<div className="grid grid-cols-8 gap-1">
{COMMON_EMOJIS.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="sm"
onClick={() => handleReaction(emoji)}
className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
>
{emoji}
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onReply(message)}
title="Reply"
>
<ReplyIcon className="size-3.5" />
</Button>
)}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && (
<DropdownMenuItem onClick={handleStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
});

View File

@ -9,13 +9,11 @@ import type { ReactNode } from 'react';
interface RoomParticipantsPanelProps {
members: RoomMemberResponse[];
membersLoading: boolean;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
members,
membersLoading,
onMention,
}: RoomParticipantsPanelProps) {
// Separate owners/admins from regular members
const admins = useMemo(
@ -48,7 +46,6 @@ export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
<ParticipantRow
key={member.user}
member={member}
onMention={onMention}
/>
))}
</ParticipantSection>
@ -61,7 +58,6 @@ export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
<ParticipantRow
key={member.user}
member={member}
onMention={onMention}
/>
))}
</ParticipantSection>
@ -94,10 +90,8 @@ function ParticipantSection({
function ParticipantRow({
member,
onMention,
}: {
member: RoomMemberResponse;
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
}) {
const username = member.user_info?.username ?? member.user;
const avatarUrl = member.user_info?.avatar_url;
@ -105,13 +99,10 @@ function ParticipantRow({
const initial = (displayName?.[0] ?? '?').toUpperCase();
return (
<button
type="button"
<div
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60',
)}
onClick={() => onMention?.(member.user, username, 'user')}
disabled={!onMention}
>
<Avatar className="h-7 w-7">
{avatarUrl ? (
@ -123,6 +114,6 @@ function ParticipantRow({
<p className="truncate text-sm font-medium text-foreground">{displayName}</p>
<p className="truncate text-[11px] text-muted-foreground">@{displayName}</p>
</div>
</button>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { memo, useState, useEffect, useCallback } from 'react';
import React, { memo, useState, useEffect, useCallback } from 'react';
import type { ModelResponse, RoomResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
import { Button } from '@/components/ui/button';
@ -151,38 +151,61 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
};
return (
<aside className="hidden w-[380px] border-l border-border/70 bg-card/50 xl:flex xl:flex-col">
<header className="flex h-12 items-center border-b border-border/70 px-3">
<p className="text-sm font-semibold text-foreground">Room Settings</p>
<aside
className="flex flex-col h-full"
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)' }}
>
<header
className="flex h-12 items-center border-b px-4 shrink-0"
style={{ borderColor: 'var(--room-border)' }}
>
<p className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>Room Settings</p>
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* General Section */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">General</h3>
<h3
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: 'var(--room-text-muted)' }}
>
General
</h3>
<div className="space-y-2">
<Label className="text-sm">Room name</Label>
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Room name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
className="border-border/70 bg-muted/30"
style={{
background: 'var(--room-bg)',
borderColor: 'var(--room-border)',
color: 'var(--room-text)',
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Public</p>
<p className="text-xs text-muted-foreground">Visible to all project members</p>
<p className="text-sm font-medium" style={{ color: 'var(--room-text)' }}>Public</p>
<p className="text-xs" style={{ color: 'var(--room-text-muted)' }}>Visible to all project members</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={setIsPublic}
aria-label="Public room visibility"
style={{ '--room-accent': 'var(--room-accent)' } as React.CSSProperties}
/>
</div>
<Button onClick={handleSave} disabled={!name.trim() || isPending} className="w-full">
<Button
onClick={handleSave}
disabled={!name.trim() || isPending}
className="w-full border-none"
style={{ background: 'var(--room-accent)', color: '#fff' }}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
@ -193,8 +216,19 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{/* AI Section */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">AI Models</h3>
<Button variant="ghost" size="sm" className="h-7 gap-1 px-2" onClick={openAddDialog}>
<h3
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: 'var(--room-text-muted)' }}
>
AI Models
</h3>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={openAddDialog}
style={{ color: 'var(--room-accent)' }}
>
<Plus className="h-3 w-3" />
Add
</Button>
@ -202,29 +236,36 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{aiConfigsLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
</div>
) : aiConfigs.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No AI models configured</p>
<p className="text-xs py-2" style={{ color: 'var(--room-text-muted)' }}>No AI models configured</p>
) : (
<div className="space-y-2">
{aiConfigs.map((config) => (
<div
key={config.model}
className="flex items-center justify-between rounded-md border border-border/70 bg-muted/30 px-3 py-2"
className="flex items-center justify-between rounded-md px-3 py-2"
style={{ border: `1px solid var(--room-border)`, background: 'var(--room-bg)' }}
>
<div className="flex items-center gap-2 min-w-0">
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate text-foreground">
<Bot className="h-4 w-4 shrink-0" style={{ color: 'var(--room-text-muted)' }} />
<span className="text-sm truncate" style={{ color: 'var(--room-text)' }}>
{availableModels.find((m) => m.id === config.model)?.name ?? config.model}
</span>
{config.stream && (
<span className="rounded bg-green-500/10 px-1 py-0.5 text-[10px] text-green-600 dark:text-green-400 shrink-0">
<span
className="rounded px-1 py-0.5 text-[10px] shrink-0"
style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}
>
streaming
</span>
)}
{config.think && (
<span className="rounded bg-blue-500/10 px-1 py-0.5 text-[10px] text-blue-600 dark:text-blue-400 shrink-0">
<span
className="rounded px-1 py-0.5 text-[10px] shrink-0"
style={{ background: 'rgba(59,130,246,0.1)', color: 'var(--room-accent)' }}
>
think
</span>
)}
@ -232,7 +273,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive shrink-0"
className="h-7 w-7 shrink-0"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => handleDeleteAi(config.model)}
>
<Trash2 className="h-3.5 w-3.5" />
@ -246,10 +288,17 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{/* Add AI Dialog */}
<Dialog open={showAiAddDialog} onOpenChange={setShowAiAddDialog}>
<DialogContent className="sm:max-w-[440px]">
<DialogContent
className="sm:max-w-[440px]"
style={{
background: 'var(--room-bg)',
border: '1px solid var(--room-border)',
color: 'var(--room-text)',
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<DialogTitle className="flex items-center gap-2" style={{ color: 'var(--room-text)' }}>
<Bot className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
Add AI Model
</DialogTitle>
</DialogHeader>
@ -257,27 +306,30 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
<div className="space-y-4 py-2">
{/* Model selection */}
<div className="space-y-2">
<Label className="text-sm">Model</Label>
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>Model</Label>
{modelsLoading ? (
<div className="flex items-center gap-2 h-8 px-3 rounded-md border border-input bg-muted/30 text-sm text-muted-foreground">
<div
className="flex items-center gap-2 h-8 px-3 rounded-md text-sm"
style={{ border: '1px solid var(--room-border)', color: 'var(--room-text-muted)' }}
>
<Loader2 className="h-4 w-4 animate-spin" />
Loading models...
</div>
) : (
<Select value={selectedModelId} onValueChange={(v) => { if (v !== null) setSelectedModelId(v); }}>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full" style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}>
<SelectValue placeholder="Select a model...">
{selectedModelId
? availableModels.find((m) => m.id === selectedModelId)?.name ?? selectedModelId
: null}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex flex-col items-start gap-0.5">
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs" style={{ color: 'var(--room-text-muted)' }}>
{model.capability} · {model.modality}
</span>
</div>
@ -290,9 +342,15 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{/* System prompt */}
<div className="space-y-2">
<Label className="text-sm">System Prompt</Label>
<Label className="text-sm" style={{ color: 'var(--room-text)' }}>System Prompt</Label>
<textarea
className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="min-h-[80px] w-full rounded-md px-3 py-2 text-sm resize-none focus-visible:outline-none focus-visible:ring-1"
style={{
border: '1px solid var(--room-border)',
background: 'var(--room-bg)',
color: 'var(--room-text)',
'--ring-color': 'var(--room-accent)',
} as React.CSSProperties}
placeholder="Optional system prompt for this AI..."
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
@ -302,7 +360,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{/* Advanced settings toggle */}
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-1 text-xs transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => setShowAdvanced((v) => !v)}
>
{showAdvanced ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
@ -313,7 +372,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
<div className="space-y-3 pl-1">
{/* Temperature */}
<div className="space-y-1">
<Label className="text-xs">Temperature</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Temperature</Label>
<Input
type="number"
step="0.1"
@ -322,43 +381,46 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
placeholder="e.g. 0.7"
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
/>
</div>
{/* Max tokens */}
<div className="space-y-1">
<Label className="text-xs">Max Tokens</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Max Tokens</Label>
<Input
type="number"
placeholder="e.g. 4096"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
/>
</div>
{/* History limit */}
<div className="space-y-1">
<Label className="text-xs">History Limit (messages)</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>History Limit (messages)</Label>
<Input
type="number"
placeholder="e.g. 50"
value={historyLimit}
onChange={(e) => setHistoryLimit(e.target.value)}
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
/>
</div>
{/* Toggles */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Use Exact</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Use Exact</Label>
<Switch checked={useExact} onCheckedChange={setUseExact} size="sm" />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Think Mode</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Think Mode</Label>
<Switch checked={think} onCheckedChange={setThink} size="sm" />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Streaming</Label>
<Label className="text-xs" style={{ color: 'var(--room-text)' }}>Streaming</Label>
<Switch checked={stream} onCheckedChange={setStream} size="sm" />
</div>
</div>
@ -367,10 +429,18 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAiAddDialog(false)}>
<Button
variant="outline"
onClick={() => setShowAiAddDialog(false)}
style={{ borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
>
Cancel
</Button>
<Button onClick={handleAddAi} disabled={!selectedModelId || isAddingAi}>
<Button
onClick={handleAddAi}
disabled={!selectedModelId || isAddingAi}
style={{ background: 'var(--room-accent)', color: '#fff', border: 'none' }}
>
{isAddingAi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Add Model
</Button>

View File

@ -0,0 +1,89 @@
/**
* AI Studio design system room-wide tokens.
* Clean, modern palette. No Discord reference.
*/
import { useTheme } from '@/contexts';
// ─── Palette ──────────────────────────────────────────────────────────────────
export const PALETTE = {
light: {
// Backgrounds
bg: '#ffffff',
bgSubtle: '#f9f9fa',
bgHover: '#f3f3f5',
bgActive: '#ebebef',
// Borders
border: '#e4e4e8',
borderFocus:'#1c7ded',
borderMuted:'#eeeeef',
// Text
text: '#1f1f1f',
textMuted: '#8a8a8f',
textSubtle: '#b8b8bd',
// Accent (primary action)
accent: '#1c7ded',
accentHover:'#1a73d4',
accentText: '#ffffff',
// Icon
icon: '#8a8a8f',
iconHover: '#5c5c62',
// Surfaces
surface: '#f7f7f8',
surface2: '#eeeeef',
// Status
online: '#22c55e',
away: '#f59e0b',
offline: '#d1d1d6',
// Mention highlight
mentionBg: 'rgba(28,125,237,0.08)',
mentionText:'#1c7ded',
// Message bubbles
msgBg: '#f9f9fb',
msgOwnBg: '#e8f0fe',
// Panel
panelBg: '#f5f5f7',
// Badges
badgeAi: 'bg-blue-50 text-blue-600',
badgeRole: 'bg-gray-100 text-gray-600',
},
dark: {
bg: '#1a1a1e',
bgSubtle: '#1e1e23',
bgHover: '#222228',
bgActive: '#2a2a30',
border: '#2e2e35',
borderFocus:'#4a9eff',
borderMuted:'#252528',
text: '#ececf1',
textMuted: '#8a8a92',
textSubtle: '#5c5c65',
accent: '#4a9eff',
accentHover:'#6aafff',
accentText: '#ffffff',
icon: '#7a7a84',
iconHover: '#b0b0ba',
surface: '#222228',
surface2: '#2a2a30',
online: '#34d399',
away: '#fbbf24',
offline: '#6b7280',
mentionBg: 'rgba(74,158,255,0.12)',
mentionText:'#4a9eff',
msgBg: '#1e1e23',
msgOwnBg: '#1a2a3a',
panelBg: '#161619',
badgeAi: 'bg-blue-900/40 text-blue-300',
badgeRole: 'bg-gray-800 text-gray-400',
},
} as const;
export type ThemePalette = typeof PALETTE.light;
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useAIPalette() {
const { resolvedTheme } = useTheme();
return resolvedTheme === 'dark' ? PALETTE.dark : PALETTE.light;
}

View File

@ -1,14 +1,21 @@
export { CreateRoomDialog } from './CreateRoomDialog';
export { DeleteRoomAlert } from './DeleteRoomAlert';
export { EditRoomDialog } from './EditRoomDialog';
export { MentionPopover } from './MentionPopover';
export { RoomAiAuthBanner } from './RoomAiAuthBanner';
export { RoomAiTasksPanel } from './RoomAiTasksPanel';
export { RoomChatPanel } from './RoomChatPanel';
export { RoomList } from './RoomList';
export { RoomMessageActions } from './RoomMessageActions';
export { RoomMessageBubble } from './RoomMessageBubble';
export { RoomMessageEditDialog } from './RoomMessageEditDialog';
export { RoomMessageList } from './RoomMessageList';
export { RoomParticipantsPanel } from './RoomParticipantsPanel';
export { RoomSettingsPanel } from './RoomSettingsPanel';
export { RoomMessageSearch } from './RoomMessageSearch';
export { RoomMentionPanel } from './RoomMentionPanel';
export { RoomThreadPanel } from './RoomThreadPanel';
// Discord-style components
export { DiscordChannelSidebar } from './DiscordChannelSidebar';
export { DiscordMemberList } from './DiscordMemberList';
export { DiscordChatPanel } from './DiscordChatPanel';
// Re-export from message/ sub-directory
export { MessageInput, MessageList, MessageBubble } from './message';

View File

@ -0,0 +1,157 @@
'use client';
/**
* Message action toolbar (reply, copy, more menu).
*/
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { COMMON_EMOJIS } from '../shared';
import { SmilePlus, Reply as ReplyIcon, Copy, MoreHorizontal, MessageSquare, History, Edit2, Trash2 } from 'lucide-react';
import type { MessageWithMeta } from '@/contexts';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
interface MessageActionsProps {
message: MessageWithMeta;
isOwner: boolean;
onReply?: (message: MessageWithMeta) => void;
onCreateThread?: (message: MessageWithMeta) => void;
onViewHistory?: (message: MessageWithMeta) => void;
onStartEdit?: () => void;
onRevoke?: (message: MessageWithMeta) => void;
onReaction: (emoji: string) => void;
}
export function MessageActions({
message,
isOwner,
onReply,
onCreateThread,
onViewHistory,
onStartEdit,
onRevoke,
onReaction,
}: MessageActionsProps) {
const [reactionPickerOpen, setReactionPickerOpen] = useState(false);
const handleReaction = useCallback((emoji: string) => {
onReaction(emoji);
setReactionPickerOpen(false);
}, [onReaction]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(message.content);
toast.success('Message copied');
} catch {
toast.error('Failed to copy');
}
}, [message.content]);
return (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* Add reaction */}
<Popover open={reactionPickerOpen} onOpenChange={setReactionPickerOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
}
/>
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<div className="grid grid-cols-8 gap-1">
{COMMON_EMOJIS.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="sm"
onClick={() => handleReaction(emoji)}
className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
>
{emoji}
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Reply */}
{onReply && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => onReply(message)}
title="Reply"
>
<ReplyIcon className="size-3.5" />
</Button>
)}
{/* Copy */}
{message.content_type === 'text' && (
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={handleCopy}
title="Copy"
>
<Copy className="size-3.5" />
</Button>
)}
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="More"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
{onCreateThread && !message.thread_id && (
<DropdownMenuItem onClick={() => onCreateThread(message)}>
<MessageSquare className="mr-2 size-4" />
Create thread
</DropdownMenuItem>
)}
{message.edited_at && onViewHistory && (
<DropdownMenuItem onClick={() => onViewHistory(message)}>
<History className="mr-2 size-4" />
View edit history
</DropdownMenuItem>
)}
{isOwner && message.content_type === 'text' && onStartEdit && (
<DropdownMenuItem onClick={onStartEdit}>
<Edit2 className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{isOwner && onRevoke && (
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -0,0 +1,420 @@
'use client';
/**
* Message bubble with sender info, content, and actions.
* Discord-styled for the Phase 2 redesign.
*/
import type { MessageWithMeta } from '@/contexts';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
import { formatMessageTime } from '../shared/formatters';
import { cn } from '@/lib/utils';
import { SmilePlus } from 'lucide-react';
import { useUser, useRoom, useTheme } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { ModelIcon } from '../icon-match';
import { FunctionCallBadge } from '../FunctionCallBadge';
import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel';
import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
import { MessageReactions } from './MessageReactions';
// Sender colors — AI Studio clean palette
const SENDER_COLORS: Record<string, string> = {
system: '#9ca3af',
ai: '#1c7ded',
tool: '#6b7280',
};
const DEFAULT_SENDER_COLOR = '#6b7280';
function getSenderColor(senderType: string): string {
return SENDER_COLORS[senderType] ?? DEFAULT_SENDER_COLOR;
}
const TEXT_COLLAPSE_LINE_COUNT = 5;
interface MessageBubbleProps {
message: MessageWithMeta;
roomId: string;
replyMessage?: MessageWithMeta | null;
grouped?: boolean;
showDate?: boolean;
onInlineEdit?: (message: MessageWithMeta, newContent: string) => void;
onViewHistory?: (message: MessageWithMeta) => void;
onRevoke?: (message: MessageWithMeta) => void;
onReply?: (message: MessageWithMeta) => void;
onMention?: (name: string, type: 'user' | 'ai') => void;
onOpenUserCard?: (payload: {
username: string;
displayName?: string | null;
avatarUrl?: string | null;
userId: string;
point: { x: number; y: number };
}) => void;
onOpenThread?: (message: MessageWithMeta) => void;
onCreateThread?: (message: MessageWithMeta) => void;
}
export const MessageBubble = memo(function MessageBubble({
roomId,
message,
replyMessage,
grouped = false,
showDate = true,
onInlineEdit,
onRevoke,
onReply,
onOpenUserCard,
onOpenThread,
}: MessageBubbleProps) {
const [showFullText, setShowFullText] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system';
const displayName = getSenderDisplayName(message);
const senderModelId = getSenderModelId(message);
const avatarUrl = getAvatarFromUiMessage(message);
const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
const isStreaming = !!message.is_streaming;
const isEdited = !!message.edited_at;
useTheme();
const { user } = useUser();
const { wsClient, streamingMessages, members } = useRoom();
const isOwner = user?.uid === getSenderUserUid(message);
const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true;
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
const displayContent = isStreaming && streamingMessages?.has(message.id)
? streamingMessages.get(message.id)!
: message.content;
const handleMentionClick = useCallback(
(type: string, id: string, label: string) => {
if (!onOpenUserCard || type !== 'user') return;
// Find member by id
const member = members.find((m) => m.user === id);
if (!member) return;
// Open user card near the message
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
onOpenUserCard({
username: label,
displayName: member.user_info?.username ?? label,
avatarUrl: member.user_info?.avatar_url ?? null,
userId: id,
point: { x: rect.left + 40, y: rect.top + 20 },
});
},
[onOpenUserCard, members],
);
const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return;
try {
const existing = message.reactions?.find(r => r.emoji === emoji);
if (existing?.reacted_by_me) {
await wsClient.reactionRemove(roomId, message.id, emoji);
} else {
await wsClient.reactionAdd(roomId, message.id, emoji);
}
} catch (err) {
console.warn('[RoomMessage] Failed to update reaction:', err);
}
}, [roomId, message.id, message.reactions, wsClient]);
const functionCalls = useMemo<FunctionCall[]>(
() =>
message.content_type === 'text' || message.content_type === 'Text'
? parseFunctionCalls(displayContent)
: [],
[displayContent, message.content_type],
);
const textContent = displayContent;
const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90));
}, 0);
const shouldCollapseText =
(message.content_type === 'text' || message.content_type === 'Text') &&
estimatedLines > TEXT_COLLAPSE_LINE_COUNT;
const isTextCollapsed = shouldCollapseText && !showFullText;
const handleAvatarClick = (event: React.MouseEvent<HTMLSpanElement>) => {
if (!onOpenUserCard || isAi || !isUserSender(message)) return;
if (message.sender_id) {
onOpenUserCard({
username: displayName,
avatarUrl: avatarUrl ?? null,
userId: message.sender_id,
point: { x: event.clientX, y: event.clientY },
});
}
};
const handleStartEdit = useCallback(() => {
setEditContent(message.content);
setIsEditing(true);
}, [message.content]);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditContent(message.content);
}, [message.content]);
const handleSaveEdit = useCallback(async () => {
if (!editContent.trim() || editContent === message.content) {
handleCancelEdit();
return;
}
setIsSavingEdit(true);
try {
if (onInlineEdit) {
onInlineEdit(message, editContent.trim());
}
setIsEditing(false);
} finally {
setIsSavingEdit(false);
}
}, [editContent, message, onInlineEdit, handleCancelEdit]);
const senderColor = getSenderColor(message.sender_type);
return (
<div
ref={containerRef}
className={cn(
'group relative flex items-start gap-4 px-4 transition-colors py-0.5',
!grouped && 'pt-2 pb-1',
isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5',
(isPending || isFailed) && 'opacity-60',
)}
>
{/* Avatar — Discord style, circular, 40px */}
{!grouped ? (
<button
className="shrink-0 cursor-pointer rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--room-accent)]"
onClick={handleAvatarClick}
title={displayName}
>
<Avatar className="size-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName} /> : null}
<AvatarFallback
className="text-sm font-semibold"
style={{ background: `${senderColor}22`, color: senderColor }}
>
{isAi ? <ModelIcon modelId={senderModelId} /> : initial}
</AvatarFallback>
</Avatar>
</button>
) : (
/* Timestamp column for grouped messages */
<div className="w-10 shrink-0 text-center">
{showDate && (
<span className="text-[10px]" style={{ color: 'var(--room-text-subtle)' }}>
{formatMessageTime(message.send_at).split(':').slice(0, 2).join(':')}
</span>
)}
</div>
)}
{/* Message body */}
<div className="min-w-0 flex-1">
{/* Header: name + time */}
{!grouped && (
<div className="mb-0.5 flex items-baseline gap-2 flex-wrap">
<span
className="text-[15px] font-semibold cursor-pointer hover:underline"
style={{ color: senderColor }}
onClick={handleAvatarClick}
role="button"
tabIndex={0}
>
{displayName}
</span>
<span style={{ color: 'var(--room-text-muted)' }} className="text-[11px]">{formatMessageTime(message.send_at)}</span>
{(isFailed || isPending) && (
<span className="flex items-center gap-1 text-[11px]" title={isFailed ? 'Send failed' : 'Sending...'}>
<span className={cn('size-2 rounded-full', isFailed ? 'bg-red-500' : 'bg-yellow-400 animate-pulse')} />
{isFailed ? 'Failed' : 'Sending...'}
</span>
)}
{isEdited && !isEditing && (
<span
className="text-[10px] hover:underline transition-colors cursor-pointer"
style={{ color: 'var(--room-text-subtle)' }}
title="Edited"
>
(edited)
</span>
)}
</div>
)}
{/* Inline edit mode */}
{isEditing ? (
<div className="mt-0.5 space-y-1.5">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full min-h-[52px] resize-none rounded-md border border-primary bg-background text-foreground px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); handleSaveEdit(); }
if (e.key === 'Escape') { handleCancelEdit(); }
}}
/>
<div className="flex items-center gap-2">
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>Ctrl+Enter to save · Esc to cancel</span>
<Button size="sm" onClick={handleSaveEdit} disabled={isSavingEdit || !editContent.trim()}
className="ml-auto h-7 px-3 text-xs bg-primary hover:bg-primary/90 text-primary-foreground border-none">
{isSavingEdit ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={handleCancelEdit}
className="h-7 px-3 text-xs"
style={{ color: 'var(--room-text-muted)' }}>
Cancel
</Button>
</div>
</div>
) : (
<>
{/* Revoked message */}
{isRevoked ? (
<p className="text-sm italic" style={{ color: 'var(--room-text-subtle)' }}>This message was deleted.</p>
) : (
<>
{/* Reply indicator */}
{replyMessage && (
<div className="mb-1 flex items-center gap-2 text-[12px]" style={{ color: 'var(--room-text-muted)' }}>
<span className="h-4 w-0.5 rounded-full" style={{ background: 'var(--room-border)' }} />
<span>
<span className="font-medium" style={{ color: 'var(--room-text-secondary)' }}>{getSenderDisplayName(replyMessage)}</span>
<span className="ml-1.5">
{replyMessage.content_type === 'text' || replyMessage.content_type === 'Text'
? replyMessage.content.length > 80
? `${replyMessage.content.slice(0, 80)}`
: replyMessage.content
: `[${replyMessage.content_type}]`}
</span>
</span>
</div>
)}
{/* Message content */}
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{functionCalls.length > 0 ? (
functionCalls.map((call, index) => (
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
<FunctionCallBadge functionCall={call} className="w-auto" />
</div>
))
) : (
<div className="whitespace-pre-wrap break-words">
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
</div>
)}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}
{/* Collapse gradient */}
{isTextCollapsed && (
<div
className="pointer-events-none absolute inset-x-0 -bottom-2 h-10 bg-gradient-to-t"
style={{ background: `linear-gradient(to top, var(--room-bg), transparent)` }}
/>
)}
</div>
) : (
<span style={{ color: 'var(--room-text-muted)' }}>[{message.content_type}]</span>
)}
{/* Show more/less */}
{shouldCollapseText && (
<button
onClick={() => setShowFullText(v => !v)}
className="mt-0.5 text-[12px] text-primary hover:underline"
>
{showFullText ? 'Show less' : 'Show more'}
</button>
)}
</div>
{/* Thread indicator */}
{message.thread_id && onOpenThread && (
<ThreadIndicator threadId={message.thread_id} onClick={() => onOpenThread(message)} />
)}
</>
)}
</>
)}
{/* Reactions + action row */}
{!isRevoked && !isEditing && (
<div className="flex items-center gap-1 mt-0.5">
<MessageReactions message={message} onReaction={handleReaction} />
</div>
)}
</div>
{/* Discord-style hover action buttons */}
{!isEditing && !isRevoked && !isPending && (
<div
className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute -top-3 right-3"
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
>
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => handleReaction('👍')}
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</button>
{onReply && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => onReply(message)}
title="Reply"
>
</button>
)}
{isOwner && onRevoke && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => onRevoke(message)}
title="Delete"
>
🗑
</button>
)}
{isOwner && message.content_type === 'text' && handleStartEdit && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={handleStartEdit}
title="Edit"
>
</button>
)}
</div>
)}
</div>
);
});

View File

@ -0,0 +1,93 @@
'use client';
/**
* Renders message content parses @[type:id:label] mentions into styled spans.
*/
import { cn } from '@/lib/utils';
interface MessageContentProps {
content: string;
onMentionClick?: (type: string, id: string, label: string) => void;
}
/** Parses @[type:id:label] patterns from message content */
function parseContent(content: string): Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> {
const parts: Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> = [];
const RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = RE.exec(content)) !== null) {
// Text before this match
if (match.index > lastIndex) {
parts.push({ type: 'text', text: content.slice(lastIndex, match.index) });
}
parts.push({
type: 'mention',
mention: {
type: match[1],
id: match[2],
label: match[3],
},
});
lastIndex = RE.lastIndex;
}
// Remaining text
if (lastIndex < content.length) {
parts.push({ type: 'text', text: content.slice(lastIndex) });
}
return parts;
}
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';
}
}
export function MessageContent({ content, onMentionClick }: MessageContentProps) {
const parts = parseContent(content);
return (
<div
className={cn(
'text-sm text-foreground',
'max-w-full min-w-0 break-words whitespace-pre-wrap',
'[&_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',
)}
>
{parts.map((part, i) =>
part.type === 'text' ? (
<span key={i}>{part.text}</span>
) : (
<span
key={i}
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(part.mention!.type),
)}
onClick={() => onMentionClick?.(part.mention!.type, part.mention!.id, part.mention!.label)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
e.preventDefault();
onMentionClick(part.mention!.type, part.mention!.id, part.mention!.label);
}
}}
>
<span>@{part.mention!.label}</span>
</span>
),
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
'use client';
/**
* Chat input using the production-ready TipTap IMEditor.
* Supports @mentions, file uploads, emoji picker, and rich message AST.
*/
import { forwardRef } from 'react';
import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types';
export interface MessageInputProps {
roomName: string;
onSend: (content: string) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
}
export interface MessageInputHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply },
ref,
) {
const { members } = useRoom();
// Transform room data into MentionItems
const mentionItems = {
users: members.map((m) => ({
id: m.user,
label: m.user_info?.username ?? m.user,
type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined,
})),
channels: [], // TODO: add channel mentions
ai: [], // TODO: add AI mention configs
commands: [], // TODO: add slash commands
};
// File upload handler — integrate with your upload API
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
return res.json();
};
// onSend: extract plain text from MessageAST for sending
const handleSend = (text: string, _ast: MessageAST) => {
onSend(text);
};
return (
<IMEditor
ref={ref}
replyingTo={replyingTo}
onCancelReply={onCancelReply}
onSend={handleSend}
mentionItems={mentionItems}
onUploadFile={handleUploadFile}
placeholder={`Message #${roomName}`}
/>
);
});

View File

@ -1,12 +1,19 @@
'use client';
/**
* Virtualized message list with date dividers.
*/
import type { MessageWithMeta } from '@/contexts';
import { Button } from '@/components/ui/button';
import { ArrowDown, Loader2 } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { RoomMessageBubble } from './RoomMessageBubble';
import { getSenderModelId } from './sender';
import { MessageBubble } from './MessageBubble';
import { getSenderModelId } from '../sender';
import { formatDateDivider } from '../shared/formatters';
interface RoomMessageListProps {
interface MessageListProps {
roomId: string;
messages: MessageWithMeta[];
messagesEndRef: React.RefObject<HTMLDivElement | null>;
@ -42,24 +49,6 @@ function getDateKey(iso: string): string {
return new Date(iso).toDateString();
}
function formatDateDivider(iso: string): string {
const date = new Date(iso);
const now = new Date();
const today = now.toDateString();
const yesterday = new Date(now.getTime() - 86400000).toDateString();
const key = date.toDateString();
if (key === today) return 'Today';
if (key === yesterday) return 'Yesterday';
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getSenderKey(message: MessageWithMeta): string {
const userUid = message.sender_id;
if (userUid) return `user:${userUid}`;
@ -71,12 +60,11 @@ function getSenderKey(message: MessageWithMeta): string {
return `sender:${message.sender_type}`;
}
/** Estimate message row height based on content characteristics */
function estimateMessageRowHeight(message: MessageWithMeta): number {
const lineCount = message.content.split(/\r?\n/).reduce((total, line) => {
return total + Math.max(1, Math.ceil(line.trim().length / 90));
}, 0);
const baseHeight = 24; // avatar + padding
const baseHeight = 24;
const lineHeight = 20;
const replyHeight = message.in_reply_to ? 36 : 0;
return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight;
@ -84,7 +72,7 @@ function estimateMessageRowHeight(message: MessageWithMeta): number {
const ESTIMATED_DIVIDER_HEIGHT = 30;
const RoomMessageListInner = memo(function RoomMessageListInner({
export const MessageList = memo(function MessageList({
roomId,
messages,
messagesEndRef,
@ -99,7 +87,7 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
isLoadingMore = false,
onOpenThread,
onCreateThread,
}: RoomMessageListProps) {
}: MessageListProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef<number | null>(null);
@ -108,7 +96,6 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
const isRestoringScrollRef = useRef(false);
const firstVisibleMessageIdRef = useRef<string | null>(null);
// Build reply lookup map (stable reference, recomputes only when messages change)
const replyMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
messages.forEach((m) => {
@ -117,8 +104,6 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
return map;
}, [messages]);
// Build rows: date dividers + messages
// Use a separate Map to avoid rows depending on replyMap (which changes reference)
const rows = useMemo<MessageRow[]>(() => {
const result: MessageRow[] = [];
let lastDateKey: string | null = null;
@ -155,25 +140,19 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]);
// Lightweight scroll handler: only update the "show" flag, decoupled from layout reads
// Reads scroll position synchronously but defers state updates so browser can paint first.
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Synchronous read of scroll position (triggers layout, unavoidable)
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100;
// Update state asynchronously — browser can process other frames before committing
requestAnimationFrame(() => {
setShowScrollToBottom(!nearBottom);
});
// Reset user-scrolling flag after delay
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => {
// Only clear if still at bottom
const c = scrollContainerRef.current;
if (c) {
const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
@ -192,10 +171,8 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
};
}, [handleScroll]);
// Auto-scroll when new messages arrive (only if user was already at bottom)
useEffect(() => {
if (messages.length === 0) return;
// Check if near bottom before scheduling scroll
const container = scrollContainerRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
@ -346,7 +323,7 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
data-index={virtualRow.index}
data-message-id={row.message?.id}
>
<RoomMessageBubble
<MessageBubble
roomId={roomId}
message={row.message!}
replyMessage={row.replyMessage ?? null}
@ -391,6 +368,3 @@ const RoomMessageListInner = memo(function RoomMessageListInner({
</div>
);
});
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;

View File

@ -0,0 +1,40 @@
'use client';
/**
* Reaction badges for a message bubble.
*/
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { MessageWithMeta } from '@/contexts';
interface MessageReactionsProps {
message: MessageWithMeta;
onReaction: (emoji: string) => void;
}
export function MessageReactions({ message, onReaction }: MessageReactionsProps) {
if ((message.reactions?.length ?? 0) === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{message.reactions!.map((r) => (
<Button
key={r.emoji}
variant="ghost"
size="sm"
onClick={() => onReaction(r.emoji)}
className={cn(
'h-6 gap-1 rounded-full px-2 text-xs transition-colors',
r.reacted_by_me
? 'border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border border-border bg-muted/20 text-muted-foreground hover:bg-muted/40',
)}
>
<span>{r.emoji}</span>
<span className="font-medium">{r.count}</span>
</Button>
))}
</div>
);
}

View File

@ -0,0 +1,53 @@
'use client';
/**
* Emoji reaction picker for messages.
*/
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { COMMON_EMOJIS } from '../shared';
import { SmilePlus } from 'lucide-react';
import { useState } from 'react';
interface ReactionPickerProps {
onReact: (emoji: string) => void;
}
export function ReactionPicker({ onReact }: ReactionPickerProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</Button>
}
/>
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<div className="grid grid-cols-8 gap-1">
{COMMON_EMOJIS.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="sm"
onClick={() => { onReact(emoji); setOpen(false); }}
className="size-7 p-0 text-base hover:bg-accent"
title={emoji}
>
{emoji}
</Button>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,35 @@
/**
* Custom emoji/sticker node for TipTap.
* Supports inserting image-based stickers into the editor.
*/
import { Node, mergeAttributes } from '@tiptap/core';
export const CustomEmojiNode = Node.create({
name: 'emoji',
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
name: { default: '' },
url: { default: '' },
};
},
parseHTML() {
return [{ tag: 'img[data-emoji]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'img',
mergeAttributes(HTMLAttributes, {
'data-emoji': '',
class: 'inline-block w-6 h-6 align-middle mx-0.5 select-none',
}),
];
},
});

View File

@ -0,0 +1,99 @@
/**
* File attachment node with React NodeView.
* Supports click-to-delete and status indicators (uploading/done/error).
*/
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
import { FileText, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const FileComponent = (props: { node: { attrs: Record<string, string> }; deleteNode: () => void }) => {
const { node, deleteNode } = props;
const { name, size, type, status, url } = node.attrs;
const isImage = type?.startsWith('image/');
const isUploading = status === 'uploading';
const isError = status === 'error';
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<NodeViewWrapper className="inline-block mx-1 align-middle select-none">
<span
className={cn(
'flex items-center gap-2 border rounded-lg p-1.5 pr-2 max-w-[220px] group transition-colors',
isError
? 'bg-red-50 border-red-200'
: isUploading
? 'bg-gray-50 border-gray-200'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100',
)}
>
{isImage && url ? (
<img src={url} alt={name} className="w-6 h-6 rounded object-cover shrink-0" />
) : (
<FileText size={20} className="text-blue-500 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-700" title={name}>
{name}
</span>
{size && (
<span className="text-[11px] text-gray-400">{formatSize(Number(size))}</span>
)}
</span>
{isUploading ? (
<Loader2 size={14} className="text-gray-400 animate-spin shrink-0" />
) : isError ? (
<span className="text-[10px] text-red-500 font-medium shrink-0">Failed</span>
) : null}
<button
onClick={deleteNode}
className="text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title="Remove"
>
<X size={14} />
</button>
</span>
</NodeViewWrapper>
);
};
export const FileNode = Node.create({
name: 'file',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
id: { default: null },
name: { default: '' },
url: { default: '' },
size: { default: 0 },
type: { default: '' },
status: { default: 'done' },
};
},
parseHTML() {
return [{ tag: 'span[data-type="file"]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'file' })];
},
addNodeView() {
return ReactNodeViewRenderer(FileComponent);
},
});

View File

@ -0,0 +1,502 @@
/**
* Chat input layout from Discord, colors from Google AI Studio / Linear.
*
* Layout: Discord icon toolbar left, send right, compact input
* Colors: Clean modern palette, no Discord reference
*/
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useEditor, EditorContent, Extension } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { CustomEmojiNode } from './EmojiNode';
import type { MentionItem, MessageAST, MentionType } from './types';
import { Paperclip, Smile, Send, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts';
export interface IMEditorProps {
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
onSend: (content: string, ast: MessageAST) => void;
mentionItems: {
users: MentionItem[];
channels: MentionItem[];
ai: MentionItem[];
commands: MentionItem[];
};
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
placeholder?: string;
}
export interface IMEditorHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
}
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
const LIGHT = {
bg: '#ffffff',
bgHover: '#f7f7f8',
bgActive: '#ececf1',
border: '#e3e3e5',
borderFocus: '#1c7ded',
text: '#1f1f1f',
textMuted: '#8a8a8e',
textSubtle: '#b0b0b4',
icon: '#8a8a8e',
iconHover: '#5c5c60',
sendBg: '#1c7ded',
sendBgHover: '#1a73d4',
sendIcon: '#ffffff',
sendDisabled:'#e3e3e5',
popupBg: '#ffffff',
popupBorder: '#e3e3e5',
popupHover: '#f5f5f7',
popupSelected:'#e8f0fe',
replyBg: '#f5f5f7',
badgeAi: '#dbeafe text-blue-700',
badgeChan: '#f3f4f6 text-gray-500',
badgeCmd: '#fef3c7 text-amber-700',
};
const DARK = {
bg: '#1a1a1e',
bgHover: '#222226',
bgActive: '#2a2a2f',
border: '#2e2e33',
borderFocus: '#4a9eff',
text: '#ececf1',
textMuted: '#8a8a91',
textSubtle: '#5c5c63',
icon: '#7a7a82',
iconHover: '#b0b0b8',
sendBg: '#4a9eff',
sendBgHover: '#6aafff',
sendIcon: '#ffffff',
sendDisabled:'#2e2e33',
popupBg: '#222226',
popupBorder: '#2e2e33',
popupHover: '#2a2a30',
popupSelected:'#2a3a55',
replyBg: '#1f1f23',
badgeAi: 'bg-blue-900/40 text-blue-300',
badgeChan: 'bg-gray-800 text-gray-400',
badgeCmd: 'bg-amber-900/30 text-amber-300',
};
type Palette = typeof LIGHT;
// ─── Emoji Picker ─────────────────────────────────────────────────────────────
const EMOJIS = [
{ name: 'thumbsup', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44d.png' },
{ name: 'thumbsdown', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44e.png' },
{ name: 'heart', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2764.png' },
{ name: 'laugh', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f602.png' },
{ name: 'rocket', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f680.png' },
{ name: 'fire', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f525.png' },
{ name: 'eyes', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f440.png' },
{ name: 'check', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2705.png' },
{ name: 'star', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2b50.png' },
{ name: 'clap', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44f.png' },
{ name: 'thinking', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f914.png' },
{ name: 'wave', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44b.png' },
];
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (n: string, u: string) => void; p: Palette }) {
return (
<div
className="absolute bottom-full left-0 mb-2 z-50"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 12,
boxShadow: p === DARK
? '0 8px 32px rgba(0,0,0,0.6)'
: '0 8px 32px rgba(0,0,0,0.10)',
}}
>
<div
className="flex items-center justify-between px-3 pt-3 pb-2"
style={{ borderBottom: `1px solid ${p.popupBorder}` }}
>
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: p.textMuted }}>
Emoji
</span>
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}>
<X size={11} />
</button>
</div>
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{EMOJIS.map(e => (
<button
key={e.name}
onClick={() => onSelect(e.name, e.url)}
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110"
style={{ background: 'transparent' }}
>
<img src={e.url} alt={e.name} className="w-5 h-5 pointer-events-none" />
</button>
))}
</div>
</div>
);
}
// ─── Keyboard Extension ───────────────────────────────────────────────────────
const KeyboardSend = Extension.create({
name: 'keyboardSend',
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
if (editor.isEmpty) return true;
const text = editor.getText().trim();
if (!text) return true;
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
return true;
},
'Shift-Enter': ({ editor }) => {
editor.chain().focus().setHardBreak().run();
return true;
},
};
},
addStorage() {
return { onSend: null as ((t: string, a: MessageAST) => void) | null };
},
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
}
function getBadge(type: MentionType): { label: string; cls: string } | null {
if (type === 'ai') return { label: 'AI', cls: 'bg-blue-50 text-blue-600' };
if (type === 'channel') return { label: '#', cls: 'bg-gray-100 text-gray-500' };
if (type === 'command') return { label: 'cmd', cls: 'bg-amber-50 text-amber-600' };
return null;
}
// ─── Mention Dropdown ────────────────────────────────────────────────────────
function MentionDropdown({
items, selectedIndex, onSelect, p, query,
}: {
items: MentionItem[];
selectedIndex: number;
onSelect: (item: MentionItem) => void;
p: Palette;
query: string;
}) {
return (
<div
className="absolute left-0 z-50 overflow-hidden"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 10,
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
minWidth: 240,
maxWidth: 300,
}}
>
{items.length === 0 ? (
<div className="px-4 py-5 text-sm text-center" style={{ color: p.textMuted }}>
No results for &ldquo;{query}&rdquo;
</div>
) : (
<div className="py-1 max-h-60 overflow-y-auto">
{items.map((item, i) => {
const badge = getBadge(item.type);
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{ background: i === selectedIndex ? p.popupSelected : 'transparent' }}
>
{item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0" />
) : (
<span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{ background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text }}
>
{item.label.charAt(0).toUpperCase()}
</span>
)}
<span className="flex-1 truncate text-sm font-medium" style={{ color: p.text }}>
{item.label}
</span>
{badge && (
<span className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge.label}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
// ─── Main Component ────────────────────────────────────────────────────────────
export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor(
{ replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' },
ref,
) {
const { resolvedTheme } = useTheme();
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
const [showEmoji, setShowEmoji] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
const [mentionIdx, setMentionIdx] = useState(0);
const [, setMentionPos] = useState({ top: 0, left: 0 });
const [focused, setFocused] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const allItems = [
...mentionItems.users,
...mentionItems.channels,
...mentionItems.ai,
...mentionItems.commands,
];
const selectMention = useCallback((item: MentionItem) => {
if (!editor) return;
// Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run();
setMentionOpen(false);
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: { depth: 100 } }),
Placeholder.configure({ placeholder }),
CustomEmojiNode,
KeyboardSend,
],
editorProps: {
handlePaste: (_v, e) => {
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
if (img) {
e.preventDefault();
const file = img.getAsFile();
if (file && onUploadFile) void doUpload(file);
return true;
}
return false;
},
handleDrop: (_v, e) => {
if (e.dataTransfer?.files?.[0] && onUploadFile) {
e.preventDefault();
void doUpload(e.dataTransfer.files[0]);
return true;
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
const text = ed.getText();
const { from } = ed.state.selection;
let ts = from;
for (let i = from - 1; i >= 1; i--) {
const c = text[i - 1];
if (c === '@') { ts = i; break; }
if (/\s/.test(c)) break;
}
const q = text.slice(ts - 1, from);
if (q.startsWith('@') && q.length > 1) {
const results = filterMentionItems(allItems, q.slice(1));
setMentionQuery(q.slice(1));
setMentionItems2(results);
setMentionIdx(0);
setMentionOpen(true);
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = wrapRef.current.getBoundingClientRect();
setMentionPos({ top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left) });
}
}
} else {
setMentionOpen(false);
}
},
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
useEffect(() => {
if (editor) (editor.storage as any).keyboardSend = { onSend };
}, [editor, onSend]);
const doUpload = async (file: File) => {
if (!editor || !onUploadFile) return;
try {
const res = await onUploadFile(file);
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: file.name, url: res.url, size: file.size, type: file.type, status: 'done' } }).insertContent(' ').run();
} catch { /* ignore */ }
};
const send = () => {
if (!editor || editor.isEmpty) return;
const text = editor.getText().trim();
if (!text) return;
onSend(text, editor.getJSON() as MessageAST);
editor.commands.clearContent();
};
useImperativeHandle(ref, () => ({
focus: () => editor?.commands.focus(),
clearContent: () => editor?.commands.clearContent(),
getContent: () => editor?.getText() ?? '',
}));
const hasContent = !!editor && !editor.isEmpty;
// Dynamic styles
const borderColor = focused ? p.borderFocus : p.border;
const boxShadow = focused
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
: 'none';
return (
<div className="relative flex flex-col" ref={wrapRef}>
{/* Reply strip */}
{replyingTo && (
<div
className="flex items-center gap-3 px-4 py-2.5"
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }}
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-[11px] font-semibold shrink-0" style={{ color: p.borderFocus }}>Replying to</span>
<span className="truncate text-sm font-medium" style={{ color: p.text }}>{replyingTo.display_name}</span>
<span className="truncate shrink-1 text-sm" style={{ color: p.textMuted }}> {replyingTo.content}</span>
</div>
{onCancelReply && (
<button onClick={onCancelReply} className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors" style={{ color: p.icon }}>
<X size={13} />
</button>
)}
</div>
)}
{/* Input area */}
<div
onClick={() => editor?.commands.focus()}
style={{
background: p.bg,
border: `1.5px solid ${borderColor}`,
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
boxShadow,
transition: 'border-color 160ms ease, box-shadow 160ms ease',
}}
>
{/* Mention dropdown */}
{mentionOpen && (
<MentionDropdown
items={mentionItems2}
selectedIndex={mentionIdx}
onSelect={selectMention}
p={p}
query={mentionQuery}
/>
)}
{/* Editor */}
<div
className="ai-editor"
style={{ padding: '12px 14px 10px' }}
>
<EditorContent editor={editor} />
</div>
{/* Discord-style toolbar: icons left, send right */}
<div className="flex items-center justify-between px-2 pb-2">
{/* Left — emoji + attach */}
<div className="flex items-center gap-0.5">
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }}
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{ color: showEmoji ? p.iconHover : p.icon, background: showEmoji ? p.bgActive : 'transparent' }}
title="Emoji"
>
<Smile size={18} />
</button>
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(n, u) => { editor?.chain().focus().insertContent({ type: 'emoji', attrs: { name: n, url: u } }).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
</div>
<label
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{ color: p.icon }}
title="Attach file"
>
<Paperclip size={18} />
<input type="file" className="hidden" onChange={e => { e.stopPropagation(); const f = e.target.files?.[0]; if (f && onUploadFile) void doUpload(f); e.target.value = ''; }} />
</label>
</div>
{/* Right — hint + send */}
<div className="flex items-center gap-2.5">
<span className="text-[11px]" style={{ color: p.textSubtle }}> send</span>
<button
onClick={(e) => { e.stopPropagation(); send(); }}
disabled={!hasContent}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: hasContent ? p.sendBg : p.sendDisabled,
color: hasContent ? p.sendIcon : p.icon,
opacity: hasContent ? 1 : 0.55,
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
}}
title="Send"
>
<Send size={13} />
</button>
</div>
</div>
</div>
<style>{`
.ai-editor .ProseMirror {
outline: none;
white-space: pre-wrap;
word-break: break-word;
min-height: 22px;
font-size: 15px;
line-height: 1.6;
}
.ai-editor .ProseMirror p { margin: 0; }
.ai-editor .ProseMirror p.is-editor-empty:first-child::before {
color: hsl(220 9% 60%);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ai-editor .ProseMirror::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
});
IMEditor.displayName = 'IMEditor';

View File

@ -0,0 +1,103 @@
/**
* Mention suggestion dropdown with keyboard navigation.
* Supports user, channel, ai, and command mention types.
*/
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Hash, Bot, Command, User } from 'lucide-react';
import type { MentionItem, MentionType } from './types';
import { cn } from '@/lib/utils';
interface SuggestionListProps {
items: MentionItem[];
command: (item: { id: string; label: string; type: MentionType }) => void;
}
export const SuggestionList = forwardRef<{ onKeyDown: (props: { event: KeyboardEvent }) => boolean }, SuggestionListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = items[index];
if (item) command({ id: item.id, label: item.label, type: item.type });
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === 'ArrowDown') {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === 'Enter') {
selectItem(selectedIndex);
return true;
}
if (event.key === 'Escape') {
return true;
}
return false;
},
}));
useEffect(() => setSelectedIndex(0), [items]);
const getIcon = (type: MentionType) => {
switch (type) {
case 'channel': return <Hash size={14} className="text-gray-500" />;
case 'ai': return <Bot size={14} className="text-purple-500" />;
case 'command': return <Command size={14} className="text-orange-500" />;
default: return <User size={14} className="text-blue-500" />;
}
};
const getBadge = (type: MentionType) => {
if (type === 'ai') return <span className="ml-auto text-[10px] bg-purple-100 text-purple-700 px-1.5 rounded">AI</span>;
if (type === 'command') return <span className="ml-auto text-[10px] bg-orange-100 text-orange-700 px-1.5 rounded">Cmd</span>;
if (type === 'channel') return <span className="ml-auto text-[10px] bg-gray-100 text-gray-500 px-1.5 rounded">#</span>;
return null;
};
if (items.length === 0) {
return (
<div className="bg-card border border-border rounded-lg shadow-xl overflow-hidden min-w-[200px] py-1">
<div className="px-4 py-3 text-sm text-muted-foreground text-center">No results</div>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg shadow-xl overflow-hidden min-w-[200px] max-h-[280px] overflow-y-auto py-1 z-50">
{items.map((item, index) => (
<button
key={item.id}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors text-left',
index === selectedIndex ? 'bg-accent' : 'hover:bg-muted/60',
)}
onClick={() => selectItem(index)}
>
{item.avatar ? (
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={item.avatar} alt={item.label} />
<AvatarFallback className="text-[10px]">
{item.label.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
) : (
<span className="shrink-0">{getIcon(item.type)}</span>
)}
<span className="truncate font-medium text-foreground">{item.label}</span>
{getBadge(item.type)}
</button>
))}
</div>
);
},
);
SuggestionList.displayName = 'SuggestionList';

View File

@ -0,0 +1,39 @@
/**
* TipTap suggestion configuration for @mentions and /commands.
*/
import type { Editor, Range } from '@tiptap/core';
import type { SuggestionOptions } from '@tiptap/suggestion';
import type { MentionItem } from './types';
/**
* Creates a suggestion config for the given trigger character.
* The `items` function should be injected with room context.
* Note: `editor` is injected by TipTap at runtime, so we cast to bypass TS requirement.
*/
export function createSuggestionConfig(
trigger: string,
getItems: (query: string) => MentionItem[],
) {
return {
char: trigger,
allowSpaces: false,
items: ({ query }: { query: string }) => getItems(query),
command: ({ editor, range, props }: { editor: Editor; range: Range; props: MentionItem }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: 'mention',
attrs: {
id: props.id,
label: props.label,
type: props.type,
},
})
.insertContent(' ')
.run();
},
} as unknown as Omit<SuggestionOptions<MentionItem>, 'render'>;
}

View File

@ -0,0 +1,68 @@
/**
* Core types for the IM editor (mentions, files, emojis).
*/
export type MentionType = 'user' | 'channel' | 'ai' | 'command';
export interface MentionItem {
id: string;
label: string;
type: MentionType;
avatar?: string;
}
export interface FileData {
id: string;
name: string;
url: string;
size?: number;
type: string;
status?: 'uploading' | 'done' | 'error';
progress?: number;
}
/** Output AST produced by the editor on send */
export interface MessageAST {
type: 'doc';
content: EditorNode[];
}
export type EditorNode = TextNode | MentionNode | FileNode | EmojiNode | HardBreakNode;
export interface TextNode {
type: 'text';
text: string;
}
export interface MentionNode {
type: 'mention';
attrs: {
id: string;
label: string;
type: MentionType;
};
}
export interface FileNode {
type: 'file';
attrs: {
id: string;
name: string;
url: string;
size: number;
type: string;
status: string;
};
}
export interface EmojiNode {
type: 'emoji';
attrs: {
name: string;
url: string;
};
}
export interface HardBreakNode {
type: 'hardBreak';
}

View File

@ -0,0 +1,10 @@
/**
* Message components.
*/
export { MessageInput } from './MessageInput';
export { MessageBubble } from './MessageBubble';
export { MessageList } from './MessageList';
export { MessageActions } from './MessageActions';
export { MessageReactions } from './MessageReactions';
export { ReactionPicker } from './ReactionPicker';
export { MessageContent } from './MessageContent';

View File

@ -0,0 +1,10 @@
/**
* Shared constants for room components.
*/
// ─── Reactions ────────────────────────────────────────────────────────────────
export const COMMON_EMOJIS = [
'👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🚀',
'✅', '⭐', '🔥', '💯', '👀', '🙏', '💪', '🤔',
];

View File

@ -0,0 +1,41 @@
/**
* Shared formatting utilities for room components.
*/
// ─── Message Time ─────────────────────────────────────────────────────────────
export function formatMessageTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const yesterdayMs = now.getTime() - 86400000;
const isYesterday = new Date(yesterdayMs).toDateString() === d.toDateString();
if (isToday) {
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
}
return d.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
// ─── Date Divider ─────────────────────────────────────────────────────────────
export function formatDateDivider(iso: string): string {
const date = new Date(iso);
const now = new Date();
const today = now.toDateString();
const yesterday = new Date(now.getTime() - 86400000).toDateString();
const key = date.toDateString();
if (key === today) return 'Today';
if (key === yesterday) return 'Yesterday';
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}

View File

@ -0,0 +1,5 @@
/**
* Shared utilities for room components.
*/
export { COMMON_EMOJIS } from './constants';
export { formatMessageTime, formatDateDivider } from './formatters';

View File

@ -2,4 +2,5 @@ export { UserProvider, useUser } from './user-context';
export { ProjectProvider, useProject } from './project-context';
export { WorkspaceProvider, useWorkspace, tryUseWorkspace } from './workspace-context';
export { RepositoryContextProvider, useRepo, type RepoInfo } from './repository-context';
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup } from './room-context';
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup, type RoomAiConfig } from './room-context';
export { ThemeProvider, useTheme } from './theme-context';

View File

@ -434,8 +434,8 @@ export function RoomProvider({
// Room AI configs for @ai: mention suggestions
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
// Available models (for looking up AI model names)
const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]);
// Available models (for looking up AI model names) — TODO: wire up once model sync API is available
const [_availableModels, _setAvailableModels] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
@ -1146,16 +1146,11 @@ export function RoomProvider({
}
setAiConfigsLoading(true);
try {
// Load model list fresh so we have names even if availableModels is empty
const { modelList } = await import('@/client');
const modelsResp = await modelList({});
const models = (modelsResp.data as { data?: { data?: { id: string; name: string }[] } } | undefined)
?.data?.data ?? [];
const configs = await client.aiList(activeRoomId);
setRoomAiConfigs(
configs.map((cfg) => ({
model: cfg.model,
modelName: models.find((m) => m.id === cfg.model)?.name,
modelName: cfg.model_name,
})),
);
} catch {
@ -1170,7 +1165,7 @@ export function RoomProvider({
try {
const resp = await (await import('@/client')).modelList({});
const inner = (resp.data as { data?: { data?: { id: string; name: string }[] } } | undefined);
setAvailableModels(inner?.data?.data ?? []);
_setAvailableModels(inner?.data?.data ?? []);
} catch {
// Non-fatal
}

View File

@ -45,6 +45,29 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* ── Discord layout tokens ─────────────────────────────────────────────── */
--color-discord-bg: var(--room-bg);
--color-discord-sidebar: var(--room-sidebar);
--color-discord-channel-hover: var(--room-channel-hover);
--color-discord-channel-active: var(--room-channel-active);
--color-discord-mention-badge: var(--room-mention-badge);
--color-discord-blurple: var(--room-accent);
--color-discord-blurple-hover: var(--room-accent-hover);
--color-discord-green: var(--room-online);
--color-discord-red: oklch(0.63 0.21 25);
--color-discord-yellow: oklch(0.75 0.17 80);
--color-discord-online: var(--room-online);
--color-discord-offline: var(--room-offline);
--color-discord-idle: var(--room-away);
--color-discord-mention-text: var(--room-mention-text);
--color-discord-text: var(--room-text);
--color-discord-text-secondary: var(--room-text-secondary);
--color-discord-text-muted: var(--room-text-muted);
--color-discord-text-subtle: var(--room-text-subtle);
--color-discord-border: var(--room-border);
--color-discord-hover: var(--room-hover);
--color-discord-placeholder: var(--room-text-muted);
}
:root {
@ -54,7 +77,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@ -65,55 +88,95 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--radius: 0.5rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* AI Studio room palette — light */
--room-bg: oklch(0.995 0 0);
--room-sidebar: oklch(0.99 0 0);
--room-channel-hover: oklch(0.97 0 0);
--room-channel-active: oklch(0.55 0.18 253 / 8%);
--room-mention-badge: oklch(0.55 0.18 253);
--room-accent: oklch(0.55 0.18 253);
--room-accent-hover: oklch(0.52 0.19 253);
--room-online: oklch(0.63 0.19 158);
--room-offline: oklch(0.62 0 0 / 35%);
--room-away: oklch(0.75 0.17 80);
--room-text: oklch(0.145 0 0);
--room-text-secondary: oklch(0.25 0 0);
--room-text-muted: oklch(0.50 0 0);
--room-text-subtle: oklch(0.68 0 0);
--room-border: oklch(0.91 0 0);
--room-hover: oklch(0.97 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.18 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.13 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.22 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* Discord dark theme */
--discord-bg: oklch(0.145 0 0);
--discord-sidebar: oklch(0.13 0 0);
--discord-channel-hover: oklch(0.2 0 0);
/* AI Studio room palette — dark */
--room-bg: oklch(0.11 0 0);
--room-sidebar: oklch(0.10 0 0);
--room-channel-hover: oklch(0.16 0 0);
--room-channel-active: oklch(0.58 0.18 253 / 12%);
--room-mention-badge: oklch(0.58 0.18 253);
--room-accent: oklch(0.58 0.18 253);
--room-accent-hover: oklch(0.65 0.20 253);
--room-online: oklch(0.65 0.17 158);
--room-offline: oklch(0.50 0 0 / 35%);
--room-away: oklch(0.72 0.16 80);
--room-text: oklch(0.985 0 0);
--room-text-secondary: oklch(0.985 0 0 / 80%);
--room-text-muted: oklch(0.985 0 0 / 58%);
--room-text-subtle: oklch(0.985 0 0 / 40%);
--room-border: oklch(0 0 0 / 18%);
--room-hover: oklch(0.16 0 0);
}
@layer base {
@ -133,4 +196,635 @@
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
/* ── Discord layout ──────────────────────────────────────────────────────── */
.discord-layout {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
background: var(--room-bg);
color: var(--foreground);
}
/* Server sidebar (left icon strip) */
.discord-server-sidebar {
display: flex;
flex-direction: column;
width: 72px;
background: var(--room-sidebar);
padding: 12px 0;
gap: 6px;
align-items: center;
border-right: 1px solid var(--room-border);
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
}
.discord-server-icon {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--room-channel-hover);
color: var(--foreground);
transition: border-radius 200ms ease, background 200ms ease;
font-weight: 700;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0;
user-select: none;
}
.discord-server-icon:hover {
border-radius: 16px;
background: var(--room-accent);
color: var(--primary-foreground);
}
.discord-server-icon.active {
border-radius: 16px;
background: var(--room-accent);
color: var(--primary-foreground);
}
.discord-server-icon .home-icon {
width: 28px;
height: 28px;
}
/* Channel sidebar */
.discord-channel-sidebar {
display: flex;
flex-direction: column;
width: 260px;
background: var(--room-sidebar);
border-right: 1px solid var(--room-border);
flex-shrink: 0;
overflow: hidden;
}
.discord-channel-header {
display: flex;
align-items: center;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--room-border);
box-shadow: 0 1px 0 var(--room-border);
flex-shrink: 0;
}
.discord-channel-header-title {
font-weight: 600;
font-size: 15px;
color: var(--foreground);
flex: 1;
}
.discord-channel-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 8px 0;
}
.discord-channel-list::-webkit-scrollbar {
width: 4px;
}
.discord-channel-list::-webkit-scrollbar-track {
background: transparent;
}
.discord-channel-list::-webkit-scrollbar-thumb {
background: var(--room-border);
border-radius: 4px;
}
.discord-channel-category {
margin-bottom: 4px;
}
.discord-channel-category-header {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
border-radius: 4px;
margin-bottom: 2px;
color: var(--room-text-muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
transition: color 150ms;
}
.discord-channel-category-header:hover {
color: var(--foreground);
}
.discord-channel-category-header svg {
transition: transform 150ms;
}
.discord-channel-category-header.collapsed svg {
transform: rotate(-90deg);
}
.discord-channel-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--room-text-muted);
font-size: 15px;
font-weight: 400;
transition: background 100ms, color 100ms;
user-select: none;
position: relative;
}
.discord-channel-item:hover {
background: var(--room-hover);
color: var(--room-text);
}
.discord-channel-item.active {
background: var(--room-channel-active);
color: var(--foreground);
}
.discord-channel-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 70%;
background: var(--room-accent);
border-radius: 0 2px 2px 0;
}
.discord-channel-hash {
opacity: 0.7;
font-size: 18px;
line-height: 1;
flex-shrink: 0;
}
.discord-channel-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discord-mention-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--room-mention-badge);
color: var(--primary-foreground);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.discord-add-channel-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--room-text-subtle);
font-size: 14px;
transition: color 150ms;
margin-left: 16px;
background: none;
border: none;
text-align: left;
width: 100%;
}
.discord-add-channel-btn:hover {
color: var(--room-text);
}
/* Member list sidebar */
.discord-member-sidebar {
display: flex;
flex-direction: column;
width: 220px;
background: var(--room-sidebar);
border-left: 1px solid var(--room-border);
flex-shrink: 0;
overflow: hidden;
}
.discord-member-header {
display: flex;
align-items: center;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--room-border);
flex-shrink: 0;
}
.discord-member-section-title {
display: flex;
align-items: center;
gap: 4px;
padding: 16px 12px 4px;
color: var(--room-text-subtle);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.discord-member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
color: var(--room-text-secondary);
font-size: 14px;
transition: background 100ms;
user-select: none;
}
.discord-member-item:hover {
background: var(--room-hover);
}
.discord-member-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.discord-member-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--room-sidebar);
}
.discord-member-status-dot.online {
background: var(--room-online);
}
.discord-member-status-dot.offline {
background: var(--room-offline);
}
.discord-member-status-dot.idle {
background: var(--room-away);
}
/* ── Discord message bubbles ─────────────────────────────────────────────── */
.discord-message-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 16px 8px;
}
.discord-message-list::-webkit-scrollbar {
width: 8px;
}
.discord-message-list::-webkit-scrollbar-track {
background: transparent;
}
.discord-message-list::-webkit-scrollbar-thumb {
background: var(--room-border);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.discord-message-group {
display: flex;
flex-direction: column;
}
.discord-message-row {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 1px 0;
min-height: 26px;
border-radius: 4px;
position: relative;
}
.discord-message-row:hover .discord-message-actions {
opacity: 1;
}
.discord-message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
margin-top: -2px;
}
.discord-message-body {
flex: 1;
min-width: 0;
}
.discord-message-avatar-spacer {
width: 40px;
flex-shrink: 0;
}
.discord-message-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.discord-message-author {
font-size: 15px;
font-weight: 600;
color: var(--foreground);
line-height: 1.2;
cursor: pointer;
}
.discord-message-author:hover {
text-decoration: underline;
}
.discord-message-time {
font-size: 11px;
color: var(--room-text-subtle);
line-height: 1.2;
}
.discord-message-content {
font-size: 15px;
line-height: 1.4;
color: var(--room-text);
word-break: break-word;
white-space: pre-wrap;
}
.discord-message-content .mention {
background: oklch(0.488 0.243 264.376 / 25%);
color: var(--room-mention-text);
padding: 0 4px;
border-radius: 3px;
font-weight: 500;
}
.discord-message-actions {
position: absolute;
top: -14px;
right: 16px;
display: flex;
align-items: center;
gap: 2px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px;
opacity: 0;
transition: opacity 150ms;
box-shadow: 0 1px 4px var(--room-border);
}
.discord-msg-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
color: var(--room-text-muted);
transition: background 100ms, color 100ms;
background: none;
border: none;
}
.discord-msg-action-btn:hover {
background: var(--room-hover);
color: var(--room-text);
}
.discord-date-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 16px 0 8px;
padding: 0 60px;
}
.discord-date-divider::before,
.discord-date-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--room-border);
}
.discord-date-divider-label {
font-size: 12px;
font-weight: 600;
color: var(--room-text-subtle);
white-space: nowrap;
}
/* Typing indicator */
.discord-typing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px 4px;
font-size: 12px;
color: var(--room-text-subtle);
}
.discord-typing-dots {
display: flex;
gap: 3px;
}
.discord-typing-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--room-text-subtle);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.discord-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.discord-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
/* Status pill in header */
.discord-ws-status {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--room-text-muted);
}
.discord-ws-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.discord-ws-dot.connected { background: var(--room-online); }
.discord-ws-dot.connecting {
background: var(--room-away);
animation: pulse 1.5s infinite;
}
.discord-ws-dot.disconnected { background: oklch(0.63 0.21 25); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Chat input area */
.discord-chat-input-area {
display: flex;
flex-direction: column;
padding: 0 16px 24px;
margin-top: 4px;
}
.discord-input-wrapper {
display: flex;
align-items: flex-end;
gap: 4px;
background: var(--room-hover);
border: 1px solid var(--room-border);
border-radius: 8px;
padding: 4px 4px 4px 16px;
transition: background 150ms, border-color 150ms;
}
.discord-input-wrapper:focus-within {
background: var(--room-sidebar);
border-color: var(--room-accent);
}
.discord-input-field {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--room-text);
font-size: 14px;
line-height: 1.4;
padding: 8px 0;
resize: none;
max-height: 200px;
overflow-y: auto;
font-family: inherit;
}
.discord-input-field::placeholder {
color: var(--room-text-muted);
}
.discord-input-field::-webkit-scrollbar { width: 0; }
.discord-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
background: var(--room-accent);
color: var(--primary-foreground);
cursor: pointer;
border: none;
transition: background 150ms;
flex-shrink: 0;
}
.discord-send-btn:hover { background: var(--room-accent-hover); }
.discord-send-btn:disabled { opacity: 0.5; cursor: default; }
/* Reply preview in input */
.discord-reply-preview {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
padding: 6px 8px;
border-radius: 4px;
background: var(--room-hover);
border-left: 2px solid var(--room-accent);
font-size: 13px;
color: var(--room-text-muted);
}
.discord-reply-preview-author {
font-weight: 600;
color: var(--room-text-secondary);
}
.discord-reply-preview-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Streaming message cursor */
.discord-streaming-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--room-accent);
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.8s step-end infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Role color dot */
.discord-role-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}

View File

@ -1,165 +0,0 @@
/**
* Mention AST system see architecture spec.
*
* Node types:
* text plain text
* mention @user:, @repository:, @ai: mentions
* ai_action <ai action="..."> structured AI commands (future)
*
* HTML serialization:
* <mention type="user" id="uuid-or-username">label</mention>
* <mention type="repository" id="repo-uid">repo_name</mention>
* <mention type="ai" id="model-uid">model_name</mention>
* <ai action="..."></ai>
*
* Key principle: always use ID for logic, label only for display.
*/
// ─── AST Node Types ──────────────────────────────────────────────────────────
export type MentionMentionType = 'user' | 'repository' | 'ai';
export type TextNode = {
type: 'text';
text: string;
};
export type MentionNode = {
type: 'mention';
mentionType: MentionMentionType;
id: string;
label: string;
};
export type AiActionNode = {
type: 'ai_action';
action: string;
args?: string;
};
export type Node = TextNode | MentionNode | AiActionNode;
export type Document = Node[];
// ─── Serialization (AST → HTML) ───────────────────────────────────────────────
/**
* Serialize a single AST node to HTML string.
*/
function serializeNode(node: Node): string {
if (node.type === 'text') return node.text;
if (node.type === 'ai_action') return `<ai action="${node.action}">${node.args ?? ''}</ai>`;
// mention node
const escapedId = node.id.replace(/"/g, '&quot;');
const escapedLabel = node.label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<mention type="${node.mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
}
/**
* Serialize a document (list of AST nodes) to HTML string.
*/
export function serialize(doc: Document): string {
return doc.map(serializeNode).join('');
}
// ─── Parsing (HTML → AST) ────────────────────────────────────────────────────
// Regex to match <mention type="..." id="...">label</mention>
// Works whether attributes are on one line or spread across lines.
const MENTION_RE =
/<mention\s+type="([^"]+)"\s+id="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/mention>/gi;
// Regex to match <ai action="...">args</ai>
const AI_ACTION_RE = /<ai\s+action="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/ai>/gi;
/**
* Parse an HTML string into an AST document.
* Falls back to a single text node if no structured tags are found.
*/
export function parse(html: string): Document {
if (!html) return [];
const nodes: Document = [];
let lastIndex = 0;
// We interleave all three patterns to find the earliest match.
const matchers: Array<{
re: RegExp;
type: 'mention' | 'ai_action';
}> = [
{ re: MENTION_RE, type: 'mention' },
{ re: AI_ACTION_RE, type: 'ai_action' },
];
// Reset regex lastIndex
for (const m of matchers) m.re.lastIndex = 0;
while (true) {
let earliest: { match: RegExpExecArray; type: 'mention' | 'ai_action' } | null = null;
for (const m of matchers) {
m.re.lastIndex = lastIndex;
const match = m.re.exec(html);
if (match) {
if (!earliest || match.index < earliest.match.index) {
earliest = { match, type: m.type };
}
}
}
if (!earliest) break;
const { match, type } = earliest;
// Text before this match
if (match.index > lastIndex) {
const text = html.slice(lastIndex, match.index);
if (text) nodes.push({ type: 'text', text });
}
if (type === 'mention') {
const mentionType = match[1] as MentionMentionType;
const id = match[2];
const label = match[3] ?? '';
if (
mentionType === 'user' ||
mentionType === 'repository' ||
mentionType === 'ai'
) {
nodes.push({ type: 'mention', mentionType, id, label });
} else {
// Unknown mention type — treat as text
nodes.push({ type: 'text', text: match[0] });
}
} else if (type === 'ai_action') {
const action = match[1];
const args = match[2] ?? '';
nodes.push({ type: 'ai_action', action, args });
}
lastIndex = match.index + match[0].length;
}
// Trailing text
if (lastIndex < html.length) {
const text = html.slice(lastIndex);
if (text) nodes.push({ type: 'text', text });
}
return nodes;
}
// ─── Suggestion value builders ───────────────────────────────────────────────
/**
* Build the HTML mention string from a suggestion selection.
*/
export function buildMentionHtml(
mentionType: MentionMentionType,
id: string,
label: string,
): string {
const escapedId = id.replace(/"/g, '&quot;');
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
}

View File

@ -1,9 +0,0 @@
import type { MentionSuggestion } from '@/components/room/MentionPopover';
/**
* Module-level refs shared between ChatInputArea and MentionPopover.
* Avoids stale closure / TDZ issues when components are defined in different
* parts of the same file.
*/
export const mentionSelectedIdxRef = { current: 0 };
export const mentionVisibleRef = { current: [] as MentionSuggestion[] };

View File

@ -1,201 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MentionSuggestion, MentionType, RoomAiConfig, ResolveMentionName } from './mention-types';
/** Parsed mention context from text at cursor position */
export interface MentionStateInternal {
category: string;
item: string;
hasColon: boolean;
}
export interface UseMentionStateReturn {
/** Plain text value of the input */
value: string;
/** Set value and update internal state */
setValue: (value: string) => void;
/** Current caret offset in the text */
cursorOffset: number;
/** Set caret offset without re-rendering the whole tree */
setCursorOffset: React.Dispatch<React.SetStateAction<number>>;
/** Whether the mention popover should be visible */
showMentionPopover: boolean;
/** Open/close the popover */
setShowMentionPopover: (open: boolean) => void;
/** Filtered suggestions for the current @ context */
suggestions: MentionSuggestion[];
/** Currently selected index in suggestions */
selectedIndex: number;
/** Move selection to a specific index */
setSelectedIndex: (index: number) => void;
/** Reset selection to first item */
resetSelection: () => void;
/** Parsed mention state at current cursor position */
mentionState: MentionStateInternal | null;
/** Build HTML for inserting a mention at current cursor */
buildInsertionAt: (category: MentionType, id: string, label: string) => InsertionResult;
/** Resolve ID → display name for any mention type */
resolveName: ResolveMentionName;
}
export interface InsertionResult {
html: string;
/** Start position where the mention should be inserted (before the @) */
startPos: number;
/** Total length of inserted text (+ trailing space) */
totalLength: number;
}
/**
* Central hook that manages all mention-related state for the input layer.
* Replaces the old module-level refs with clean, component-scoped state.
*
* Usage:
* const ms = useMentionState(members, repos, aiConfigs, reposLoading, aiConfigsLoading);
* // Pass ms.* props to MentionInput and MentionPopover
*/
export function useMentionState(
members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>,
repos: Array<{ uid: string; repo_name: string }> = [],
aiConfigs: RoomAiConfig[] = [],
reposLoading: boolean = false,
aiConfigsLoading: boolean = false,
): UseMentionStateReturn {
const [value, setValue] = useState('');
const [cursorOffset, setCursorOffset] = useState(0);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
// ─── Derived: Mention State ────────────────────────────────────────────────
/** Parse the @ mention context at the current cursor position */
const mentionState = useMemo<MentionStateInternal | null>(() => {
const textBefore = value.slice(0, cursorOffset);
const match = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!match) return null;
const [_full, category, _colon, item] = match;
return {
category: category.toLowerCase(),
item: (item ?? '').toLowerCase(),
hasColon: _colon !== undefined,
};
}, [value, cursorOffset]);
// ─── Derived: Suggestions ──────────────────────────────────────────────────
const suggestions = useMemo<MentionSuggestion[]>(() => {
const state = mentionState;
if (!state) return [];
const { category, item, hasColon } = state;
// No @ typed yet or just started typing — show categories
if (!category) {
return [
{ type: 'category', category: 'repository', label: 'Repository' },
{ type: 'category', category: 'user', label: 'User' },
{ type: 'category', category: 'ai', label: 'AI' },
];
}
// Has @ but no colon — filter to matching categories
if (!hasColon) {
const catLabel: Record<string, string> = { repository: 'Repository', user: 'User', ai: 'AI' };
return [{ type: 'category', category: category as MentionType, label: catLabel[category] ?? category }];
}
// Has @type: — show items
if (category === 'repository') {
if (reposLoading) return [{ type: 'category', label: 'Loading...' }];
return repos
.filter(r => !item || r.repo_name.toLowerCase().includes(item))
.slice(0, 12)
.map(r => ({ type: 'item', category: 'repository' as const, label: r.repo_name, mentionId: r.uid }));
}
if (category === 'user') {
return members
.filter(m => m.role !== 'ai')
.filter(m => {
const name = m.user_info?.username ?? m.user;
return !item || name.toLowerCase().includes(item);
})
.slice(0, 12)
.map(m => {
const name = m.user_info?.username ?? m.user;
return {
type: 'item' as const, category: 'user' as const, label: name, mentionId: m.user,
avatar: m.user_info?.avatar_url,
};
});
}
if (category === 'ai') {
if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }];
return aiConfigs
.filter(c => {
const name = c.modelName ?? c.model;
return !item || name.toLowerCase().includes(item);
})
.slice(0, 12)
.map(c => ({
type: 'item' as const, category: 'ai' as const, label: c.modelName ?? c.model, mentionId: c.model,
}));
}
return [];
}, [mentionState, members, repos, aiConfigs, reposLoading, aiConfigsLoading]);
// ─── Auto-select First Item ────────────────────────────────────────────────
useEffect(() => {
setSelectedIndex(0);
}, [suggestions.length]);
// ─── Name Resolution ───────────────────────────────────────────────────────
const resolveName: ResolveMentionName = useCallback((type, id, fallback) => {
switch (type) {
case 'user': {
const member = members.find(m => m.user === id);
return member?.user_info?.username ?? member?.user ?? fallback;
}
case 'repository': {
const repo = repos.find(r => r.uid === id);
return repo?.repo_name ?? fallback;
}
case 'ai': {
const cfg = aiConfigs.find(c => c.model === id);
return cfg?.modelName ?? cfg?.model ?? fallback;
}
}
}, [members, repos, aiConfigs]);
// ─── Insertion Builder ─────────────────────────────────────────────────────
/** Build the HTML string and compute insertion start position */
const buildInsertionAt = useCallback((mentionType: MentionType, id: string, label: string): InsertionResult => {
const escapedId = id.replace(/"/g, '&quot;');
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
const spacer = ' ';
const totalLength = html.length + spacer.length;
return { html, startPos: cursorOffset - countAtPattern(value, cursorOffset), totalLength };
}, [value, cursorOffset]);
return {
value, setValue, cursorOffset, setCursorOffset,
showMentionPopover, setShowMentionPopover,
suggestions, selectedIndex, setSelectedIndex,
resetSelection: () => setSelectedIndex(0),
mentionState, buildInsertionAt, resolveName,
};
}
/** Count how many characters the @mention pattern occupies before cursor */
function countAtPattern(text: string, cursorPos: number): number {
const before = text.slice(0, cursorPos);
const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
return match ? match[0].length : 0;
}

View File

@ -1,34 +0,0 @@
/** Shared types for the mention system. Centralized to avoid circular dependencies. */
// ─── Core Types ──────────────────────────────────────────────────────────────
export type MentionType = 'user' | 'repository' | 'ai';
export interface RoomAiConfig {
model: string;
modelName?: string;
}
// ─── Suggestion Types ────────────────────────────────────────────────────────
export interface MentionSuggestionCategory {
type: 'category';
category: MentionType;
label: string;
}
export interface MentionSuggestionItem {
type: 'item';
category: MentionType;
label: string;
sublabel?: string;
mentionId: string;
avatar?: string | null;
}
export type MentionSuggestion = MentionSuggestionCategory | MentionSuggestionItem;
// ─── Resolver Interface ──────────────────────────────────────────────────────
/** Resolves a mention's ID → display name at render time. */
export type ResolveMentionName = (type: MentionType, id: string, fallback: string) => string;

View File

@ -332,6 +332,7 @@ export interface MessageEditHistoryResponse {
export interface AiConfigData {
room: string;
model: string;
model_name?: string;
version?: string;
call_count: number;
last_call_at?: string;