From 00a5369fe1d867fa9e78b4053d8b3828006d74bf Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 16:59:36 +0800 Subject: [PATCH] feat(frontend): Discord layout + AI Studio theme + Room Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .next/cache/.previewinfo | 1 + .next/cache/.rscinfo | 1 + .next/diagnostics/build-diagnostics.json | 6 + .next/diagnostics/framework.json | 1 + .next/package.json | 1 + .next/trace | 1 + .next/trace-build | 1 + .next/turbopack | 0 .next/types/cache-life.d.ts | 145 ++++ .next/types/routes.d.ts | 88 +++ .next/types/validator.ts | 286 +++++++ deploy/templates/ingress.yaml | 1 + libs/room/src/ai.rs | 32 +- libs/room/src/helpers.rs | 1 + libs/room/src/service.rs | 76 +- libs/room/src/types.rs | 1 + openapi.json | 4 + package.json | 11 +- pnpm-lock.yaml | 649 ++++++++++++++++ src/app/project/room.tsx | 23 +- src/client/types.gen.ts | 3 + src/components/room/DiscordChannelSidebar.tsx | 198 +++++ src/components/room/DiscordChatPanel.tsx | 360 +++++++++ src/components/room/DiscordMemberList.tsx | 226 ++++++ src/components/room/MentionInput.tsx | 247 ------ src/components/room/MentionPopover.tsx | 514 ------------ src/components/room/MessageMentions.tsx | 284 ------- src/components/room/RoomChatPanel.tsx | 231 +----- src/components/room/RoomMessageBubble.tsx | 512 ------------ src/components/room/RoomParticipantsPanel.tsx | 13 +- src/components/room/RoomSettingsPanel.tsx | 146 +++- src/components/room/design-system.ts | 89 +++ src/components/room/index.ts | 15 +- .../room/message/MessageActions.tsx | 157 ++++ src/components/room/message/MessageBubble.tsx | 420 ++++++++++ .../room/message/MessageContent.tsx | 93 +++ src/components/room/message/MessageInput.tsx | 70 ++ .../MessageList.tsx} | 54 +- .../room/message/MessageReactions.tsx | 40 + .../room/message/ReactionPicker.tsx | 53 ++ .../room/message/editor/EmojiNode.tsx | 35 + .../room/message/editor/FileNode.tsx | 99 +++ .../room/message/editor/IMEditor.tsx | 502 ++++++++++++ .../room/message/editor/SuggestionList.tsx | 103 +++ .../room/message/editor/suggestions.ts | 39 + src/components/room/message/editor/types.ts | 68 ++ src/components/room/message/index.ts | 10 + src/components/room/shared/constants.tsx | 10 + src/components/room/shared/formatters.ts | 41 + src/components/room/shared/index.ts | 5 + src/contexts/index.ts | 3 +- src/contexts/room-context.tsx | 13 +- src/index.css | 730 +++++++++++++++++- src/lib/mention-ast.ts | 165 ---- src/lib/mention-refs.ts | 9 - src/lib/mention-state.ts | 201 ----- src/lib/mention-types.ts | 34 - src/lib/ws-protocol.ts | 1 + 58 files changed, 4803 insertions(+), 2319 deletions(-) create mode 100644 .next/cache/.previewinfo create mode 100644 .next/cache/.rscinfo create mode 100644 .next/diagnostics/build-diagnostics.json create mode 100644 .next/diagnostics/framework.json create mode 100644 .next/package.json create mode 100644 .next/trace create mode 100644 .next/trace-build create mode 100644 .next/turbopack create mode 100644 .next/types/cache-life.d.ts create mode 100644 .next/types/routes.d.ts create mode 100644 .next/types/validator.ts create mode 100644 src/components/room/DiscordChannelSidebar.tsx create mode 100644 src/components/room/DiscordChatPanel.tsx create mode 100644 src/components/room/DiscordMemberList.tsx delete mode 100644 src/components/room/MentionInput.tsx delete mode 100644 src/components/room/MentionPopover.tsx delete mode 100644 src/components/room/MessageMentions.tsx delete mode 100644 src/components/room/RoomMessageBubble.tsx create mode 100644 src/components/room/design-system.ts create mode 100644 src/components/room/message/MessageActions.tsx create mode 100644 src/components/room/message/MessageBubble.tsx create mode 100644 src/components/room/message/MessageContent.tsx create mode 100644 src/components/room/message/MessageInput.tsx rename src/components/room/{RoomMessageList.tsx => message/MessageList.tsx} (87%) create mode 100644 src/components/room/message/MessageReactions.tsx create mode 100644 src/components/room/message/ReactionPicker.tsx create mode 100644 src/components/room/message/editor/EmojiNode.tsx create mode 100644 src/components/room/message/editor/FileNode.tsx create mode 100644 src/components/room/message/editor/IMEditor.tsx create mode 100644 src/components/room/message/editor/SuggestionList.tsx create mode 100644 src/components/room/message/editor/suggestions.ts create mode 100644 src/components/room/message/editor/types.ts create mode 100644 src/components/room/message/index.ts create mode 100644 src/components/room/shared/constants.tsx create mode 100644 src/components/room/shared/formatters.ts create mode 100644 src/components/room/shared/index.ts delete mode 100644 src/lib/mention-ast.ts delete mode 100644 src/lib/mention-refs.ts delete mode 100644 src/lib/mention-state.ts delete mode 100644 src/lib/mention-types.ts diff --git a/.next/cache/.previewinfo b/.next/cache/.previewinfo new file mode 100644 index 0000000..ea94f85 --- /dev/null +++ b/.next/cache/.previewinfo @@ -0,0 +1 @@ +{"previewModeId":"ce3f288bfc676d22dc4c8451698f43ff","previewModeSigningKey":"07f92b6308a6126f36d71e78252c52e029f15d27f2d9eb156ebeba1b7c17b831","previewModeEncryptionKey":"efdc9600f7d4bcef633ac1e3be3348b32564789ea4916ea5adafb5b512cbe456","expireAt":1777702293556} \ No newline at end of file diff --git a/.next/cache/.rscinfo b/.next/cache/.rscinfo new file mode 100644 index 0000000..d4381f8 --- /dev/null +++ b/.next/cache/.rscinfo @@ -0,0 +1 @@ +{"encryption.key":"Hqnv4g8ZhwVTYVrYYvM4IYBPhIetrEjilPilE28S0JY=","encryption.expire_at":1777702293517} \ No newline at end of file diff --git a/.next/diagnostics/build-diagnostics.json b/.next/diagnostics/build-diagnostics.json new file mode 100644 index 0000000..49f3647 --- /dev/null +++ b/.next/diagnostics/build-diagnostics.json @@ -0,0 +1,6 @@ +{ + "buildStage": "compile", + "buildOptions": { + "useBuildWorker": "true" + } +} \ No newline at end of file diff --git a/.next/diagnostics/framework.json b/.next/diagnostics/framework.json new file mode 100644 index 0000000..a2d2b98 --- /dev/null +++ b/.next/diagnostics/framework.json @@ -0,0 +1 @@ +{"name":"Next.js","version":"16.2.4"} \ No newline at end of file diff --git a/.next/package.json b/.next/package.json new file mode 100644 index 0000000..7156107 --- /dev/null +++ b/.next/package.json @@ -0,0 +1 @@ +{"type": "commonjs"} \ No newline at end of file diff --git a/.next/trace b/.next/trace new file mode 100644 index 0000000..9a1571d --- /dev/null +++ b/.next/trace @@ -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"}] diff --git a/.next/trace-build b/.next/trace-build new file mode 100644 index 0000000..3f57633 --- /dev/null +++ b/.next/trace-build @@ -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"}] diff --git a/.next/turbopack b/.next/turbopack new file mode 100644 index 0000000..e69de29 diff --git a/.next/types/cache-life.d.ts b/.next/types/cache-life.d.ts new file mode 100644 index 0000000..a8c6997 --- /dev/null +++ b/.next/types/cache-life.d.ts @@ -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 +} diff --git a/.next/types/routes.d.ts b/.next/types/routes.d.ts new file mode 100644 index 0000000..1da2e94 --- /dev/null +++ b/.next/types/routes.d.ts @@ -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 = 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
Blog post: {slug}
+ * } + * ``` + */ + interface PageProps { + params: Promise + searchParams: Promise> + } + + /** + * Props for Next.js App Router layout components + * @example + * ```tsx + * export default function Layout(props: LayoutProps<'/dashboard'>) { + * return
{props.children}
+ * } + * ``` + */ + type LayoutProps = { + params: Promise + children: React.ReactNode + } & { + [K in LayoutSlotMap[LayoutRoute]]: React.ReactNode + } +} diff --git a/.next/types/validator.ts b/.next/types/validator.ts new file mode 100644 index 0000000..b74ec57 --- /dev/null +++ b/.next/types/validator.ts @@ -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 = { + default: React.ComponentType<{ params: Promise } & any> | ((props: { params: Promise } & any) => React.ReactNode | Promise | never | void | Promise) + generateStaticParams?: (props: { params: ParamMap[Route] }) => Promise | any[] + generateMetadata?: ( + props: { params: Promise } & any, + parent: ResolvingMetadata + ) => Promise | any + generateViewport?: ( + props: { params: Promise } & any, + parent: ResolvingViewport + ) => Promise | any + metadata?: any + viewport?: any +} + +type LayoutConfig = { + default: React.ComponentType> | ((props: LayoutProps) => React.ReactNode | Promise | never | void | Promise) + generateStaticParams?: (props: { params: ParamMap[Route] }) => Promise | any[] + generateMetadata?: ( + props: { params: Promise } & any, + parent: ResolvingMetadata + ) => Promise | any + generateViewport?: ( + props: { params: Promise } & any, + parent: ResolvingViewport + ) => Promise | any + metadata?: any + viewport?: any +} + + +// Validate ../../src/app/about/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/about/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/docs/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/docs/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/homepage/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/homepage/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/network/api/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/network/api/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/network/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/network/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/network/rooms/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/network/rooms/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/notify/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/notify/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/pricing/enterprise/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/pricing/enterprise/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/pricing/faq/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/pricing/faq/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/pricing/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/pricing/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/search/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/search/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/skills/docs/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/skills/docs/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/skills/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/skills/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/skills/publish/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/skills/publish/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/solutions/governance/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/solutions/governance/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/solutions/memory/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/solutions/memory/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/solutions/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/solutions/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/solutions/rooms/page.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/solutions/rooms/page.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + + + + + + + +// Validate ../../src/app/homepage/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/homepage/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/notify/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/notify/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/project/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/project/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/project/repo/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/project/repo/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/repository/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/repository/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/repository/settings/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/repository/settings/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/settings/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/settings/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} + +// Validate ../../src/app/workspace/layout.tsx +{ + type __IsExpected> = Specific + const handler = {} as typeof import("../../src/app/workspace/layout.js") + type __Check = __IsExpected + // @ts-ignore + type __Unused = __Check +} diff --git a/deploy/templates/ingress.yaml b/deploy/templates/ingress.yaml index 130da26..cd51980 100644 --- a/deploy/templates/ingress.yaml +++ b/deploy/templates/ingress.yaml @@ -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: diff --git a/libs/room/src/ai.rs b/libs/room/src/ai.rs index aef3e5d..89726e8 100644 --- a/libs/room/src/ai.rs +++ b/libs/room/src/ai.rs @@ -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( diff --git a/libs/room/src/helpers.rs b/libs/room/src/helpers.rs index 80b9877..b34bce8 100644 --- a/libs/room/src/helpers.rs +++ b/libs/room/src/helpers.rs @@ -109,6 +109,7 @@ impl From 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, diff --git a/libs/room/src/service.rs b/libs/room/src/service.rs index e39df2d..8ddd2d9 100644 --- a/libs/room/src/service.rs +++ b/libs/room/src/service.rs @@ -24,10 +24,11 @@ use models::agent_task::AgentType; const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024; +/// Legacy: uuid or username static USER_MENTION_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| regex_lite::Regex::new(r"\s*([^<]+?)\s*").unwrap()); -/// Matches label +/// Legacy: label static MENTION_TAG_RE: LazyLock regex_lite::Regex> = LazyLock::new(|| { regex_lite::Regex::new( @@ -36,6 +37,13 @@ static MENTION_TAG_RE: LazyLock 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> = + 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 `uuid` format - /// and the new `label` format. + /// Extracts user UUIDs from all mention formats: + /// - Legacy: `uuid` + /// - Legacy: `label` + /// - New: `@[user:uuid:label]` pub fn extract_mentions(content: &str) -> Vec { let mut mentioned = Vec::new(); @@ -559,7 +569,7 @@ impl RoomService { } } - // New label format + // Legacy label 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 `...` format and the - /// new `label` format. + /// Resolves user mentions from all formats: + /// - Legacy: `uuid` or `username` + /// - Legacy: `label` + /// - 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 { 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 } diff --git a/libs/room/src/types.rs b/libs/room/src/types.rs index 273f429..a79665b 100644 --- a/libs/room/src/types.rs +++ b/libs/room/src/types.rs @@ -260,6 +260,7 @@ pub struct RoomAiUpsertRequest { pub struct RoomAiResponse { pub room: Uuid, pub model: Uuid, + pub model_name: Option, pub version: Option, pub call_count: i64, pub last_call_at: Option>, diff --git a/openapi.json b/openapi.json index 468f91a..f5bad00 100644 --- a/openapi.json +++ b/openapi.json @@ -37970,6 +37970,10 @@ "type": "string", "format": "uuid" }, + "model_name": { + "type": "string", + "nullable": true + }, "version": { "type": [ "string", diff --git a/package.json b/package.json index 55d61ee..d0d304f 100644 --- a/package.json +++ b/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391d3d0..fbb64b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/project/room.tsx b/src/app/project/room.tsx index 94e1d27..bcbcb27 100644 --- a/src/app/project/room.tsx +++ b/src/app/project/room.tsx @@ -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 ( -
- + {/* Channel sidebar */} + + {/* Main chat area */} {activeRoom ? ( - setDeleteDialogOpen(true)} /> ) : ( -
-
-

Select a channel

-

+

+
+
#
+

Select a channel

+

Choose a channel from the sidebar to start chatting.

diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index 699b394..09efac0 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -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; diff --git a/src/components/room/DiscordChannelSidebar.tsx b/src/components/room/DiscordChannelSidebar.tsx new file mode 100644 index 0000000..3be474d --- /dev/null +++ b/src/components/room/DiscordChannelSidebar.tsx @@ -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 ( +
+ + + {!isCollapsed && ( +
    + {rooms.map((room) => { + const isSelected = selectedRoomId === room.id; + const unreadCount = (room as RoomWithUnread).unread_count ?? 0; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +}); + +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(); + 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>({}); + + 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 ( +
+ {/* Header */} +
+
+ {projectName} +
+
+ {onOpenSettings && ( + + )} +
+
+ + {/* Channel list */} +
+ {/* Uncategorized */} + {uncategorized.length > 0 && ( + + )} + + {/* Categorized */} + {sortedCategoryNames.map((catName) => ( + toggleCategory(catName)} + /> + ))} + + {rooms.length === 0 && ( +
+

No channels yet

+ +
+ )} +
+ + {/* Footer: user / add channel */} +
+ +
+
+ ); +}); diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx new file mode 100644 index 0000000..8a13043 --- /dev/null +++ b/src/components/room/DiscordChatPanel.tsx @@ -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(null); + const messageInputRef = useRef(null); + + const [replyingTo, setReplyingTo] = useState(null); + const [editingMessage, setEditingMessage] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editHistoryDialogOpen, setEditHistoryDialogOpen] = useState(false); + const [selectedMessageForHistory, setSelectedMessageForHistory] = useState(''); + 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 ( +
+ {/* ── Header ─────────────────────────────────────────────── */} +
+
+ {room.public + ? + : + } +

{room.room_name}

+
+ + {wsStatus !== 'open' && wsStatus !== 'idle' && ( + + {wsStatus === 'connecting' ? 'Connecting...' : wsError ?? 'Disconnected'} + + )} +
+
+ +
+ + + + + + + + + {/* Settings — opens Room Settings */} + {isAdmin && ( + + )} + + {isAdmin && ( + + )} + + +
+
+ + {/* ── Body ──────────────────────────────────────────────── */} +
+
+ + + setReplyingTo(null)} + /> +
+ + {showMemberList && ( + {}} + aiConfigs={roomAiConfigs} + /> + )} +
+ + {/* ── Slide Panels ──────────────────────────────────────── */} + {showSettings && ( + + )} + + {showMentions && ( + + )} + + {activeThread && ( + setActiveThread(null)} + /> + )} + + + + +
+ ); +} diff --git a/src/components/room/DiscordMemberList.tsx b/src/components/room/DiscordMemberList.tsx new file mode 100644 index 0000000..4795be4 --- /dev/null +++ b/src/components/room/DiscordMemberList.tsx @@ -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 = { + 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 ( + + ); +} + +function MemberSection({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
{children}
+
+ ); +} + +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 ( +
+
+ Members + {members.length} +
+ +
+ {membersLoading ? ( +
+ +
+ ) : ( + <> + {/* AI section — always at top */} + {aiConfigs.length > 0 && ( + } + > + {aiConfigs.map((ai) => { + const label = ai.modelName ?? ai.model; + return ( + + ); + })} + + )} + + {(admins.length > 0 || moderators.length > 0) && ( + } + > + {[...admins, ...moderators].map((member) => ( + + ))} + + )} + + {regularMembers.length > 0 && ( + } + > + {regularMembers.map((member) => ( + + ))} + + )} + + )} +
+
+ ); +}); diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx deleted file mode 100644 index a6a46e2..0000000 --- a/src/components/room/MentionInput.tsx +++ /dev/null @@ -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 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 = { - 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 = { - user: '👤', repository: '📦', ai: '🤖', -}; - -const LABEL_MAP: Record = { - user: 'User', repository: 'Repo', ai: 'AI', -}; - -/** Escape HTML special characters */ -function escapeHtml(s: string): string { - return s.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, '
'); - } - - let html = ''; - for (const node of nodes) { - if (node.type === 'text') { - html += escapeHtml((node as { type: 'text'; text: string }).text).replace(/\n/g, '
'); - } 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 += ``; - html += `${icon}${escapeHtml(label)}: ${escapeHtml(m.label)}`; - } - // 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(function MentionInput({ - value, - onChange, - placeholder, - disabled, - onSend, -}, ref) { - const containerRef = useRef(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).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) => { - // 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) => { - e.preventDefault(); - document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); - }, []); - - return ( -
- ); -}); diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx deleted file mode 100644 index 896ca96..0000000 --- a/src/components/room/MentionPopover.tsx +++ /dev/null @@ -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; - /** 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 = { - repository: { - icon: , 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: , 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: , 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) ? ( - - {part} - - ) : ( - {part} - ), - )} - - ); -} - -function CategoryHeader({ suggestion, isSelected }: { suggestion: MentionSuggestion; isSelected: boolean }) { - const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null; - return ( -
-
- {config?.icon ?? } -
- {suggestion.label} - -
- ); -} - -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 ( -
e.preventDefault()} - onClick={onSelect} onMouseEnter={onMouseEnter} - > - {/* Icon */} -
- {suggestion.category === 'ai' ? ( -
- -
- ) : suggestion.category === 'repository' ? ( -
- -
- ) : suggestion.avatar ? ( - - - - {suggestion.label.slice(0, 2).toUpperCase()} - - - ) : ( -
- - {suggestion.label[0]?.toUpperCase()} - -
- )} - {/* Selection dot */} -
-
- - {/* Text */} -
-
- - - - {suggestion.category && ( - - {suggestion.category} - - )} -
- {suggestion.sublabel && ( - {suggestion.sublabel} - )} -
- - {/* Check mark */} -
- -
-
- ); -} - -function LoadingSkeleton() { - return ( -
- {[1, 2, 3].map(i => ( -
- -
-
- ))} -
- ); -} - -function EmptyState({ loading }: { loading?: boolean }) { - return ( -
-
- {loading ? ( -
- ) : ( - - )} -
-

{loading ? 'Loading...' : 'No matches found'}

-

{loading ? 'Please wait' : 'Try a different search term'}

-
- ); -} - -// ─── Main Component ────────────────────────────────────────────────────────── - -export function MentionPopover({ - members, repos, aiConfigs, reposLoading, aiConfigsLoading, - containerRef, inputValue, cursorPosition, onSelect, onOpenChange, onCategoryEnter, - suggestions, selectedIndex, setSelectedIndex, -}: MentionPopoverProps) { - const popoverRef = useRef(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([]); - 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({ 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 ( -
- {/* Header */} -
-
- @ -
-
- {mentionState.hasColon ? ( - <> - {mentionState.category} - {mentionState.item && (<> - / - {mentionState.item} - )} - - ) : ( - Type to filter - )} -
-
- ↑↓ - navigate - | - - select -
-
- - {/* Content */} - {suggestions.length > 0 ? ( - -
- {suggestions.map((s, i) => s.type === 'category' ? ( -
- -
- ) : ( -
{ itemsRef.current[i] = el; }}> - doInsert(s)} - onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }} - searchTerm={mentionState.item} - /> -
- ))} -
-
- ) : isLoading ? : } - - {/* Footer */} - {suggestions[selectedIndex]?.type === 'item' && ( -
-
- - {suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].category : ''} - - {suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].label : ''} -
-
- )} -
- ); -} - -/** 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(';'); -} diff --git a/src/components/room/MessageMentions.tsx b/src/components/room/MessageMentions.tsx deleted file mode 100644 index 7459f68..0000000 --- a/src/components/room/MessageMentions.tsx +++ /dev/null @@ -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: - * - `name` (old backend format) - * - `@type:name` (old colon format) - * - `name` (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 = ``; - 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 = { - 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 {node.text}; - } - if (node.type === 'mention') { - const displayName = resolveName(node.mentionType, node.id, node.label); - - const iconMap: Record = { - ai: '🤖', - user: '👤', - repository: '📦', - notify: '🔔', - }; - const labelMap: Record = { - ai: 'AI', - user: 'User', - repository: 'Repo', - notify: 'Notify', - }; - - const icon = iconMap[node.mentionType] ?? '🏷'; - const label = labelMap[node.mentionType] ?? 'Mention'; - - return ( - - ); - } - if (node.type === 'ai_action') { - return ( - - /{node.action} - {node.args ? ` ${node.args}` : ''} - - ); - } - return null; -} - -/** Renders message content with @mention highlighting using styled spans. - * Supports the new `label` 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 and 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 ( -
- {nodes.map((node, i) => renderNode(node, i, resolveName))} -
- ); -}); - -/** 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; -} diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index edf03d9..75c9d10 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -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(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(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 (
{replyingTo && ( @@ -204,79 +73,40 @@ const ChatInputArea = memo(function ChatInputArea({ )}
- { - onDraftChange(v); - ms.setValue(v); - }} - onSend={() => { - const content = ms.value.trim(); - if (content && !isSending) { - onSend(content); - onClearDraft(); +