feat(frontend): Discord layout + AI Studio theme + Room Settings
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:
parent
aac32b1b92
commit
00a5369fe1
1
.next/cache/.previewinfo
vendored
Normal file
1
.next/cache/.previewinfo
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"previewModeId":"ce3f288bfc676d22dc4c8451698f43ff","previewModeSigningKey":"07f92b6308a6126f36d71e78252c52e029f15d27f2d9eb156ebeba1b7c17b831","previewModeEncryptionKey":"efdc9600f7d4bcef633ac1e3be3348b32564789ea4916ea5adafb5b512cbe456","expireAt":1777702293556}
|
||||
1
.next/cache/.rscinfo
vendored
Normal file
1
.next/cache/.rscinfo
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"encryption.key":"Hqnv4g8ZhwVTYVrYYvM4IYBPhIetrEjilPilE28S0JY=","encryption.expire_at":1777702293517}
|
||||
6
.next/diagnostics/build-diagnostics.json
Normal file
6
.next/diagnostics/build-diagnostics.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"buildStage": "compile",
|
||||
"buildOptions": {
|
||||
"useBuildWorker": "true"
|
||||
}
|
||||
}
|
||||
1
.next/diagnostics/framework.json
Normal file
1
.next/diagnostics/framework.json
Normal file
@ -0,0 +1 @@
|
||||
{"name":"Next.js","version":"16.2.4"}
|
||||
1
.next/package.json
Normal file
1
.next/package.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
||||
1
.next/trace
Normal file
1
.next/trace
Normal 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
1
.next/trace-build
Normal 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
0
.next/turbopack
Normal file
145
.next/types/cache-life.d.ts
vendored
Normal file
145
.next/types/cache-life.d.ts
vendored
Normal 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
88
.next/types/routes.d.ts
vendored
Normal 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
286
.next/types/validator.ts
Normal 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
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -37970,6 +37970,10 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
11
package.json
11
package.json
@ -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
649
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
198
src/components/room/DiscordChannelSidebar.tsx
Normal file
198
src/components/room/DiscordChannelSidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
360
src/components/room/DiscordChatPanel.tsx
Normal file
360
src/components/room/DiscordChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
src/components/room/DiscordMemberList.tsx
Normal file
226
src/components/room/DiscordMemberList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -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(';');
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
89
src/components/room/design-system.ts
Normal file
89
src/components/room/design-system.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
157
src/components/room/message/MessageActions.tsx
Normal file
157
src/components/room/message/MessageActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
420
src/components/room/message/MessageBubble.tsx
Normal file
420
src/components/room/message/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
93
src/components/room/message/MessageContent.tsx
Normal file
93
src/components/room/message/MessageContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/room/message/MessageInput.tsx
Normal file
70
src/components/room/message/MessageInput.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -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;
|
||||
40
src/components/room/message/MessageReactions.tsx
Normal file
40
src/components/room/message/MessageReactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/room/message/ReactionPicker.tsx
Normal file
53
src/components/room/message/ReactionPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/room/message/editor/EmojiNode.tsx
Normal file
35
src/components/room/message/editor/EmojiNode.tsx
Normal 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',
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
99
src/components/room/message/editor/FileNode.tsx
Normal file
99
src/components/room/message/editor/FileNode.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
502
src/components/room/message/editor/IMEditor.tsx
Normal file
502
src/components/room/message/editor/IMEditor.tsx
Normal 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 “{query}”
|
||||
</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';
|
||||
103
src/components/room/message/editor/SuggestionList.tsx
Normal file
103
src/components/room/message/editor/SuggestionList.tsx
Normal 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';
|
||||
39
src/components/room/message/editor/suggestions.ts
Normal file
39
src/components/room/message/editor/suggestions.ts
Normal 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'>;
|
||||
}
|
||||
68
src/components/room/message/editor/types.ts
Normal file
68
src/components/room/message/editor/types.ts
Normal 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';
|
||||
}
|
||||
10
src/components/room/message/index.ts
Normal file
10
src/components/room/message/index.ts
Normal 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';
|
||||
10
src/components/room/shared/constants.tsx
Normal file
10
src/components/room/shared/constants.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared constants for room components.
|
||||
*/
|
||||
|
||||
// ─── Reactions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const COMMON_EMOJIS = [
|
||||
'👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🚀',
|
||||
'✅', '⭐', '🔥', '💯', '👀', '🙏', '💪', '🤔',
|
||||
];
|
||||
41
src/components/room/shared/formatters.ts
Normal file
41
src/components/room/shared/formatters.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
5
src/components/room/shared/index.ts
Normal file
5
src/components/room/shared/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared utilities for room components.
|
||||
*/
|
||||
export { COMMON_EMOJIS } from './constants';
|
||||
export { formatMessageTime, formatDateDivider } from './formatters';
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
730
src/index.css
730
src/index.css
@ -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;
|
||||
}
|
||||
@ -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, '"');
|
||||
const escapedLabel = node.label.replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '"');
|
||||
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
|
||||
}
|
||||
@ -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[] };
|
||||
@ -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, '"');
|
||||
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||
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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user