diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index 9bebe1750..d224dd2ce 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -16,6 +16,7 @@ const ROUTE_PAGE_TITLES: Record = { "/tools": "Tools", "/memory": "Memory", "/memory-sources": "Memory Sources", + "/literature-review": "Literature Review", "/settings": "Settings" }; diff --git a/App/frontend/desktop/src/api/tests/http.test.ts b/App/frontend/desktop/src/api/tests/http.test.ts index ade8a1810..392051d99 100644 --- a/App/frontend/desktop/src/api/tests/http.test.ts +++ b/App/frontend/desktop/src/api/tests/http.test.ts @@ -121,4 +121,5 @@ describe("requestJson", () => { }) ); }); + }); diff --git a/App/frontend/desktop/src/app/router.tsx b/App/frontend/desktop/src/app/router.tsx index 3f4bf7e66..fb0b645c0 100644 --- a/App/frontend/desktop/src/app/router.tsx +++ b/App/frontend/desktop/src/app/router.tsx @@ -56,6 +56,7 @@ import { ApiKeyPage } from "../pages/api-key-page.js"; import { ApiKeyOptionalPage } from "../pages/api-key-optional-page.js"; import { ModelPage } from "../pages/model-page.js"; import { HomePage } from "../pages/home-page.js"; +import { LiteratureReviewPage } from "../pages/literature-review-page.js"; import { LoginPage } from "../pages/login-page.js"; import { MemoryPage, writeMemorySubPage } from "../pages/memory-page.js"; import { OnboardingPage } from "../pages/onboarding-page.js"; @@ -374,6 +375,8 @@ function renderRoute(path: AppRoutePath) { return ; case "/memory-sources": return ; + case "/literature-review": + return ; case "/settings": return ; case "/pet": diff --git a/App/frontend/desktop/src/app/routes.ts b/App/frontend/desktop/src/app/routes.ts index fc98a487f..303e09036 100644 --- a/App/frontend/desktop/src/app/routes.ts +++ b/App/frontend/desktop/src/app/routes.ts @@ -18,6 +18,7 @@ export type AppRoutePath = | "/tools" | "/memory" | "/memory-sources" + | "/literature-review" | "/settings"; const CURRENT_ROUTE_STORAGE_KEY = "memmy.currentRoute"; @@ -54,6 +55,7 @@ export const routeTable: Record = { "/tools": { path: "/tools", navKey: "nav.tools", requiresBootstrap: true }, "/memory": { path: "/memory", navKey: "nav.memory", requiresBootstrap: true }, "/memory-sources": { path: "/memory-sources", navKey: "nav.memory", requiresBootstrap: true }, + "/literature-review": { path: "/literature-review", navKey: "nav.literatureReview", requiresBootstrap: true }, "/settings": { path: "/settings", navKey: "nav.settings", requiresBootstrap: true } }; diff --git a/App/frontend/desktop/src/app/tests/routes.test.ts b/App/frontend/desktop/src/app/tests/routes.test.ts index 3000e3b14..0b3bd6b39 100644 --- a/App/frontend/desktop/src/app/tests/routes.test.ts +++ b/App/frontend/desktop/src/app/tests/routes.test.ts @@ -98,6 +98,7 @@ describe("desktop route table", () => { "/tools", "/memory", "/memory-sources", + "/literature-review", "/settings" ]); }); diff --git a/App/frontend/desktop/src/components/file-type-icon.tsx b/App/frontend/desktop/src/components/file-type-icon.tsx new file mode 100644 index 000000000..9e2f716cc --- /dev/null +++ b/App/frontend/desktop/src/components/file-type-icon.tsx @@ -0,0 +1,306 @@ +import type { DesktopSystemFolderIconKind } from "@memmy/desktop-interface"; +import { useEffect, useState } from "react"; +import { resolveFileType, type FileDisplayKind } from "../lib/file-type.js"; + +export type FileTypeIconSurface = "inline" | "row" | "card"; + +const systemFileIconCache = new Map(); +const pendingSystemFileIcons = new Map>(); +const systemFolderIconCache = new Map(); +const pendingSystemFolderIcons = new Map>(); + +function requestSystemFileIcon(filePath: string): Promise { + if (systemFileIconCache.has(filePath)) { + return Promise.resolve(systemFileIconCache.get(filePath) ?? null); + } + const pending = pendingSystemFileIcons.get(filePath); + if (pending) return pending; + const request = window.memmy?.getSystemFileIcon(filePath) + .catch(() => null) + .then((icon) => { + systemFileIconCache.set(filePath, icon); + pendingSystemFileIcons.delete(filePath); + return icon; + }) ?? Promise.resolve(null); + pendingSystemFileIcons.set(filePath, request); + return request; +} + +function useSystemFileIcon(filePath?: string): string | null { + const [icon, setIcon] = useState(() => ( + filePath && systemFileIconCache.has(filePath) + ? systemFileIconCache.get(filePath) ?? null + : null + )); + + useEffect(() => { + if (!filePath || typeof window === "undefined" || !window.memmy) { + setIcon(null); + return; + } + let active = true; + setIcon(systemFileIconCache.get(filePath) ?? null); + void requestSystemFileIcon(filePath).then((nextIcon) => { + if (active) setIcon(nextIcon); + }); + return () => { + active = false; + }; + }, [filePath]); + + return icon; +} + +function requestSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise { + if (systemFolderIconCache.has(kind)) { + return Promise.resolve(systemFolderIconCache.get(kind) ?? null); + } + const pending = pendingSystemFolderIcons.get(kind); + if (pending) return pending; + const request = window.memmy?.getSystemFolderIcon(kind) + .catch(() => null) + .then((icon) => { + systemFolderIconCache.set(kind, icon); + pendingSystemFolderIcons.delete(kind); + return icon; + }) ?? Promise.resolve(null); + pendingSystemFolderIcons.set(kind, request); + return request; +} + +function useSystemFolderIcon(kind: DesktopSystemFolderIconKind | null): string | null { + const [icon, setIcon] = useState(() => ( + kind && systemFolderIconCache.has(kind) ? systemFolderIconCache.get(kind) ?? null : null + )); + + useEffect(() => { + if (!kind || typeof window === "undefined" || !window.memmy) { + setIcon(null); + return; + } + let active = true; + setIcon(systemFolderIconCache.get(kind) ?? null); + void requestSystemFolderIcon(kind).then((nextIcon) => { + if (active) setIcon(nextIcon); + }); + return () => { + active = false; + }; + }, [kind]); + + return icon; +} + +function FileKindGlyph({ kind, compact }: { kind: FileDisplayKind; compact: boolean }) { + const commonProps = { + className: "file-type-icon__glyph", + fill: "none", + stroke: "currentColor", + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + strokeWidth: compact ? 1.65 : 1.35 + }; + switch (kind) { + case "pdf": + return ( + + ); + case "word": + return ( + + + {!compact ? : null} + + ); + case "spreadsheet": + return ( + + + + + ); + case "presentation": + return ( + + + + + ); + case "markdown": + return ( + + + + ); + case "code": + return ( + + + {!compact ? : null} + + ); + case "image": + return ( + + + + + + ); + case "video": + return ( + + + + + ); + case "audio": + return ( + + + + + + ); + case "archive": + return ( + + + + + ); + case "text": + case "generic": + return ( + + + + ); + } +} + +function DocumentSheet({ + kind, + surface, + formatLabel +}: { + kind: FileDisplayKind; + surface: FileTypeIconSurface; + formatLabel: string; +}) { + const compact = surface === "inline"; + return ( + + ); +} + +function FolderSheet({ open, surface }: { open?: boolean; surface: FileTypeIconSurface }) { + return ( + + ); +} + +export function FileTypeIcon(props: { + name: string; + mime?: string; + filePath?: string; + surface?: FileTypeIconSurface; + className?: string; +}) { + const resolved = resolveFileType(props.name, props.mime); + const surface = props.surface ?? "row"; + const systemIcon = useSystemFileIcon(props.filePath); + const formatLabel = resolved.shortLabel; + return ( + + {systemIcon ? ( + + ) : ( + + )} + + ); +} + +export function FolderTypeIcon(props: { + open?: boolean; + /** Opt-in only so repeated folder rows do not trigger native icon IPC storms. */ + preferSystemIcon?: boolean; + systemKind?: DesktopSystemFolderIconKind; + surface?: FileTypeIconSurface; + className?: string; +}) { + const surface = props.surface ?? "row"; + const systemIcon = useSystemFolderIcon( + props.preferSystemIcon ? (props.systemKind ?? "folder") : null + ); + return ( + + {systemIcon ? ( + + ) : ( + + )} + + ); +} diff --git a/App/frontend/desktop/src/global.d.ts b/App/frontend/desktop/src/global.d.ts index 01a013b51..0df82a5d0 100644 --- a/App/frontend/desktop/src/global.d.ts +++ b/App/frontend/desktop/src/global.d.ts @@ -1,5 +1,5 @@ /** Global.d module. */ -import type { DesktopAppInfo, DesktopImageActionRequest, DesktopImageSaveResult, DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateInstallResult } from "@memmy/desktop-interface"; +import type { DesktopAppInfo, DesktopImageActionRequest, DesktopImageSaveResult, DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopSystemFileIconResult, DesktopSystemFolderIconKind, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateInstallResult } from "@memmy/desktop-interface"; declare global { type MemmyMicrophoneAccessStatus = "not-determined" | "granted" | "denied" | "restricted" | "unsupported"; @@ -38,6 +38,10 @@ declare global { openMailto(mailtoUrl: string): Promise; copyImageToClipboard(request: DesktopImageActionRequest): Promise; saveImage(request: DesktopImageActionRequest): Promise; + getPathForFile(file: File): string; + getSystemFileIcon(filePath: string): Promise; + getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise; + showItemInFolder(filePath: string): Promise; exportMemoryDatabase(): Promise<{ canceled: true } | { canceled: false; exportPath: string; bytes: number }>; installCliTools(): Promise; restartMemoryService(): Promise; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 6e543e742..e05128a70 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -66,6 +66,7 @@ export const zhCNMessages = { "nav.pet": "桌宠", "nav.tools": "连接与工具", "nav.memory": "记忆管理", + "nav.literatureReview": "文献综述", "nav.settings": "设置", "legal.back": "返回", "legal.terms": "服务协议", @@ -418,7 +419,7 @@ export const zhCNMessages = { "home.send": "发送", "home.stop": "停止", "home.scrollToLatest": "回到最新", - "home.commandPalette.commands": "指令", + "home.commandPalette.commands": "能力", "home.command.newTitle": "新对话", "home.command.newDescription": "停止当前任务,并开始一段全新的对话。", "home.command.stopTitle": "停止当前任务", @@ -466,9 +467,12 @@ export const zhCNMessages = { "home.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", "home.asrFailedWithMessage": "语音识别失败:{message}", "home.asrFailed": "语音识别失败,请重试", - "home.suggestion.one": "整理最近的 Agent 任务", - "home.suggestion.two": "总结我的代码偏好", - "home.suggestion.three": "检查今天的待办", + "home.suggestion.one": "帮我写一篇关于 AI Memory 研究的文献综述", + "home.suggestion.two": "帮我总结一下本周的工作", + "home.suggestion.three": "梳理我最近的一个任务,并列出可行的待办", + "home.suggestionPrompt.one": "请围绕 AI Memory 的核心技术、代表性研究与发展趋势,撰写一篇结构完整、包含引用和参考文献的中文文献综述", + "home.suggestionPrompt.two": "请结合我本周的任务、讨论和完成记录,总结关键进展、重要结论与遗留问题,并给出下周优先事项", + "home.suggestionPrompt.three": "请从我最近的任务中选择一个最值得继续推进的,梳理目标、当前进展和阻塞,并列出按优先级排序的可执行待办", "home.memoryLoading": "正在召回真实记忆", "home.memoryUnavailable": "记忆服务未连接", "home.memoryEmpty": "已连接真实记忆服务,未召回到相关记忆", @@ -1372,7 +1376,126 @@ export const zhCNMessages = { "pet.agentEmptyResponse": "Agent 已完成但没有返回文本", "pet.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", "pet.asrFailedWithMessage": "语音识别失败:{message}", - "pet.asrFailed": "语音识别失败,请重试" + "pet.asrFailed": "语音识别失败,请重试", + "home.quick.attach": "添加资料", + "home.quick.attachHint": "添加文件或文件夹", + "home.quick.uploadFile": "上传文件", + "home.quick.uploadFolder": "上传文件夹", + "composer.addToChat": "添加到聊天", + "home.quick.capability": "能力", + "home.quick.capabilityHint": "选择 Agent 能力", + "home.quick.kind.file": "文件", + "home.quick.kind.folder": "文件夹", + "home.capability.literatureReview": "文献综述", + "home.capability.literatureReviewHint": "检索、阅读并生成结构化文献综述", + "home.capability.literatureReviewArgHint": "[综述主题]", + "home.capability.more": "更多能力", + "home.capability.moreHint": "输入 / 查看全部能力", + "litrev.title.setup": "需求确认", + "litrev.title.execution": "执行中", + "litrev.scene": "文献综述", + "litrev.scene.lockedHint": "当前会话已锁定为文献综述工作流", + "litrev.thinking": "思考中", + "litrev.stage.done": "已完成", + "litrev.stage.current": "进行中", + "litrev.toolCall": "Agent 工具调用", + "litrev.question.recommended": "推荐", + "litrev.question.status.preparing": "正在准备提问", + "litrev.question.cardTitle": "确认研究需求", + "litrev.question.count": "{count} 个问题", + "litrev.question.confirm": "确认并继续", + "litrev.question.supplementPlaceholder": "其他补充…", + "litrev.stageActivity.questions.generating": "生成问题中", + "litrev.stageActivity.questions.waiting": "等待用户回答", + "litrev.stageActivity.questions.done": "向用户提问", + "litrev.stageActivity.sources.waiting": "等待添加本地文献", + "litrev.stageActivity.sources.done": "本地文献", + "litrev.stageActivity.keywords.generating": "生成关键词中", + "litrev.stageActivity.keywords.waiting": "等待用户调整", + "litrev.stageActivity.keywords.done": "关键词列表", + "litrev.stageActivity.outline.generating": "生成大纲中", + "litrev.stageActivity.outline.waiting": "等待用户调整", + "litrev.stageActivity.outline.done": "大纲", + "litrev.stageActivity.references.generating": "检索文献中", + "litrev.stageActivity.references.waiting": "等待用户调整", + "litrev.stageActivity.references.done": "文献列表", + "litrev.stageActivity.tasks.generating": "生成任务列表中", + "litrev.stageActivity.tasks.done": "任务列表", + "litrev.stageActivity.preparation.done": "已完成研究准备", + "litrev.activity.durationSeconds": "{seconds} 秒", + "litrev.stageOutput.questions": "收到,研究主题和时间范围已经明确。你可以先补充本机已有文献。", + "litrev.stageOutput.sources": "本机文献已确认。我会结合这些资料生成检索关键词。", + "litrev.stageOutput.keywords": "关键词已经确认。我会围绕核心概念、方法机制和评测维度组织综述结构。", + "litrev.stageOutput.outline": "大纲已经确定。接下来检索并筛选与各章节直接相关的文献。", + "litrev.stageOutput.references": "参考文献范围已经确认。我先把任务列出来,然后开始整理资料和撰写正文。", + "litrev.workflow.close": "跳过当前步骤", + "litrev.workflow.skippedTitle": "取消{stage}", + "litrev.stageActivity.skipped": "已跳过「{stage}」", + "litrev.sources.title": "添加本地文献", + "litrev.sources.existingHint": "已带入前面上传的资料。你可以继续补充文件或文件夹,再进入关键词确认。", + "litrev.sources.empty": "尚未添加本地文献,也可以跳过并直接开始联网检索。", + "litrev.sources.none": "未添加本地文献", + "litrev.sources.count": "已纳入 {count} 个文件", + "litrev.sources.policy": "支持 PDF、DOCX、DOC、TXT、MD;文件和文件夹将作为本地检索范围。", + "litrev.sources.folderMeta": "{count} 个文件 · {size}", + "litrev.sources.folderSelected": "本地文件夹", + "litrev.sources.unsupported": "已忽略 {count} 个不支持格式的文件,仅允许 PDF、DOCX、DOC、TXT、MD。", + "litrev.sources.addFiles": "添加文件", + "litrev.sources.addFolder": "添加文件夹", + "litrev.sources.confirm": "确认资料并继续", + "litrev.sources.skip": "暂不添加,继续", + "litrev.wizard.step.keywords": "检索关键词", + "litrev.wizard.step.outline": "综述大纲", + "litrev.wizard.step.references": "参考文献来源", + "litrev.keywords.hint": "确认或调整检索关键词,可增删", + "litrev.keywords.itemLabel": "关键词", + "litrev.keywords.selectAll": "全选", + "litrev.keywords.weight": "权重", + "litrev.keywords.add": "添加关键词", + "litrev.keywords.addPlaceholder": "输入新关键词后回车", + "litrev.keywords.confirm": "确认关键词", + "litrev.outline.hint": "上下拖动调整顺序;向右拖为二级,向左拖回一级", + "litrev.outline.itemLabel": "章节", + "litrev.outline.dragHint": "拖动调整顺序和层级", + "litrev.outline.addSection": "添加一级大纲", + "litrev.outline.newSection": "新一级大纲", + "litrev.outline.addSubsection": "添加二级大纲", + "litrev.outline.newSubsection": "新二级大纲", + "litrev.outline.confirm": "确认大纲", + "litrev.refs.local": "本机已有", + "litrev.refs.fromWeb": "需联网获取", + "litrev.refs.abstractOnly": "仅摘要", + "litrev.refs.note": "Agent 已根据主题筛选候选文献。勾选最终纳入范围;需要联网的全文将在任务开始后自动获取。", + "litrev.refs.selected": "已选择 {selected} / {total}", + "litrev.refs.selectAll": "全选参考文献", + "litrev.refs.referenceColumn": "文献", + "litrev.refs.statusColumn": "获取状态", + "litrev.refs.confirmStart": "确认并开始", + "litrev.composer.setup": "发送新消息,不会提交当前卡片…", + "litrev.composer.task": "随时补充要求,Agent 会实时调整…", + "litrev.todo.progress": "正在执行计划 · {done}/{total}", + "litrev.todo.running": "执行中", + "litrev.todo.output.downloaded": "文献已经下载并校验完成。我先阅读摘要和正文,整理能够支撑各章节的证据。", + "litrev.todo.output.read": "资料阅读完成,核心方法、系统差异和评测结果已经归类。下面开始撰写 LaTeX 正文。", + "litrev.todo.output.drafted": "LaTeX 初稿已经生成。我现在补齐参考文献,并统一引用格式。", + "litrev.todo.output.references": "参考文献已经整理完成。最后生成 PDF 和 DOCX,并检查每条正文引用是否都有对应来源。", + "litrev.todo.output.checked": "PDF、DOCX 生成与引用检查完成,LaTeX 源文件、PDF 和 DOCX 已经可以交付。", + "litrev.preview.currentProject": "当前项目", + "litrev.preview.taskFolder": "文献综述任务", + "litrev.preview.projectEmpty": "项目中暂无文件", + "litrev.preview.projectEmptyDetail": "项目文件和 Agent 生成的内容会显示在这里。", + "litrev.preview.taskEmpty": "任务文件夹为空", + "litrev.preview.taskEmptyDetail": "Agent 下载或生成文件后,会自动显示在这个任务文件夹中。", + "litrev.preview.toggleFiles": "展开或收起文件树", + "litrev.preview.resizeFiles": "调整文件树宽度", + "litrev.preview.outputs": "成果", + "litrev.preview.files": "文件", + "litrev.preview.openFiles": "打开的文件", + "litrev.workspace.title": "工作区", + "litrev.workspace.toggle": "展开或收起任务文件", + "litrev.workspace.resize": "调整工作区宽度", + "litrev.workspace.emptyTitle": "还没有可预览的文件", + "litrev.workspace.emptyDesc": "任务执行后,产出的文件会显示在这里" } as const; export const enUSMessages: Record = { @@ -1430,6 +1553,7 @@ export const enUSMessages: Record = { "nav.pet": "Pet", "nav.tools": "Tool connections", "nav.memory": "Memory", + "nav.literatureReview": "Literature review", "nav.settings": "Settings", "legal.back": "Back", "legal.terms": "Terms of Service", @@ -1782,7 +1906,7 @@ export const enUSMessages: Record = { "home.send": "Send", "home.stop": "Stop", "home.scrollToLatest": "Jump to latest", - "home.commandPalette.commands": "Commands", + "home.commandPalette.commands": "Capabilities", "home.command.newTitle": "New chat", "home.command.newDescription": "Stop the current task and start a fresh conversation.", "home.command.stopTitle": "Stop current task", @@ -1830,9 +1954,12 @@ export const enUSMessages: Record = { "home.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", "home.asrFailedWithMessage": "Speech recognition failed: {message}", "home.asrFailed": "Speech recognition failed. Please try again.", - "home.suggestion.one": "Organize recent Agent tasks", - "home.suggestion.two": "Summarize my coding preferences", - "home.suggestion.three": "Review today's todos", + "home.suggestion.one": "Write a literature review of AI Memory research", + "home.suggestion.two": "Summarize my work this week", + "home.suggestion.three": "Review a recent task and list actionable todos", + "home.suggestionPrompt.one": "Write a structured Chinese literature review of AI Memory's core techniques, representative research, and trends, with citations and references", + "home.suggestionPrompt.two": "Review my tasks, discussions, and completed work this week; summarize key progress, conclusions, open issues, and next week's priorities", + "home.suggestionPrompt.three": "Choose the most valuable recent task to continue, summarize its goal, progress, and blockers, then list prioritized actionable todos", "home.memoryLoading": "Recalling real memory", "home.memoryUnavailable": "Memory service is not connected", "home.memoryEmpty": "Real memory service connected; no related memories were recalled", @@ -2736,7 +2863,126 @@ export const enUSMessages: Record = { "pet.agentEmptyResponse": "Agent completed without returning text", "pet.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", "pet.asrFailedWithMessage": "Speech recognition failed: {message}", - "pet.asrFailed": "Speech recognition failed. Please try again." + "pet.asrFailed": "Speech recognition failed. Please try again.", + "home.quick.attach": "Add sources", + "home.quick.attachHint": "Add files or folders", + "home.quick.uploadFile": "Upload files", + "home.quick.uploadFolder": "Upload folder", + "composer.addToChat": "Add to chat", + "home.quick.capability": "Capabilities", + "home.quick.capabilityHint": "Choose an Agent capability", + "home.quick.kind.file": "File", + "home.quick.kind.folder": "Folder", + "home.capability.literatureReview": "Literature review", + "home.capability.literatureReviewHint": "Search, read, and generate a structured literature review", + "home.capability.literatureReviewArgHint": "[topic]", + "home.capability.more": "More capabilities", + "home.capability.moreHint": "Type / to see all capabilities", + "litrev.title.setup": "Scoping", + "litrev.title.execution": "Running", + "litrev.scene": "Literature review", + "litrev.scene.lockedHint": "This session is locked to the literature review workflow", + "litrev.thinking": "Thinking", + "litrev.stage.done": "Done", + "litrev.stage.current": "In progress", + "litrev.toolCall": "Agent tool call", + "litrev.question.recommended": "Recommended", + "litrev.question.status.preparing": "Preparing a question", + "litrev.question.cardTitle": "Confirm research requirements", + "litrev.question.count": "{count} questions", + "litrev.question.confirm": "Confirm and continue", + "litrev.question.supplementPlaceholder": "Other details…", + "litrev.stageActivity.questions.generating": "Generating questions", + "litrev.stageActivity.questions.waiting": "Waiting for your answers", + "litrev.stageActivity.questions.done": "Asked the user", + "litrev.stageActivity.sources.waiting": "Waiting for local papers", + "litrev.stageActivity.sources.done": "Local papers", + "litrev.stageActivity.keywords.generating": "Generating keywords", + "litrev.stageActivity.keywords.waiting": "Waiting for adjustments", + "litrev.stageActivity.keywords.done": "Keyword list", + "litrev.stageActivity.outline.generating": "Generating outline", + "litrev.stageActivity.outline.waiting": "Waiting for adjustments", + "litrev.stageActivity.outline.done": "Outline", + "litrev.stageActivity.references.generating": "Searching references", + "litrev.stageActivity.references.waiting": "Waiting for adjustments", + "litrev.stageActivity.references.done": "Reference list", + "litrev.stageActivity.tasks.generating": "Generating task list", + "litrev.stageActivity.tasks.done": "Task list", + "litrev.stageActivity.preparation.done": "Research preparation completed", + "litrev.activity.durationSeconds": "{seconds}s", + "litrev.stageOutput.questions": "Got it—the research topic and date range are clear. You can add local papers before we continue.", + "litrev.stageOutput.sources": "The local sources are confirmed. I’ll use them while generating the search keywords.", + "litrev.stageOutput.keywords": "The keywords are confirmed. I’ll structure the review around core concepts, method mechanics, and evaluation.", + "litrev.stageOutput.outline": "The outline is set. Next I’ll search for and screen papers that directly support each section.", + "litrev.stageOutput.references": "The reference scope is confirmed. I’ll create the task list, then begin organizing the material and drafting the review.", + "litrev.workflow.close": "Skip this step", + "litrev.workflow.skippedTitle": "Cancel {stage}", + "litrev.stageActivity.skipped": "Skipped “{stage}”", + "litrev.sources.title": "Add local papers", + "litrev.sources.existingHint": "Sources uploaded earlier are already included. Add more files or folders before confirming keywords.", + "litrev.sources.empty": "No local papers yet. You can also skip this step and search online.", + "litrev.sources.none": "No local papers added", + "litrev.sources.count": "{count} files included", + "litrev.sources.policy": "PDF, DOCX, DOC, TXT, and MD are supported. Files and folders define the local search scope.", + "litrev.sources.folderMeta": "{count} files · {size}", + "litrev.sources.folderSelected": "Local folder", + "litrev.sources.unsupported": "Skipped {count} unsupported files. Only PDF, DOCX, DOC, TXT, and MD are allowed.", + "litrev.sources.addFiles": "Add files", + "litrev.sources.addFolder": "Add folder", + "litrev.sources.confirm": "Confirm sources", + "litrev.sources.skip": "Skip and continue", + "litrev.wizard.step.keywords": "Search keywords", + "litrev.wizard.step.outline": "Review outline", + "litrev.wizard.step.references": "Reference sources", + "litrev.keywords.hint": "Confirm or adjust the search keywords; add or remove freely", + "litrev.keywords.itemLabel": "Keyword", + "litrev.keywords.selectAll": "Select all", + "litrev.keywords.weight": "Weight", + "litrev.keywords.add": "Add keyword", + "litrev.keywords.addPlaceholder": "Type a keyword and press Enter", + "litrev.keywords.confirm": "Confirm keywords", + "litrev.outline.hint": "Drag vertically to reorder; drag right for level two or left for level one", + "litrev.outline.itemLabel": "Section", + "litrev.outline.dragHint": "Drag to change order and level", + "litrev.outline.addSection": "Add top-level outline", + "litrev.outline.newSection": "New top-level outline", + "litrev.outline.addSubsection": "Add second-level outline", + "litrev.outline.newSubsection": "New second-level outline", + "litrev.outline.confirm": "Confirm outline", + "litrev.refs.local": "Available locally", + "litrev.refs.fromWeb": "Needs download", + "litrev.refs.abstractOnly": "Abstract only", + "litrev.refs.note": "The Agent shortlisted papers for this topic. Select the final scope; online full texts will be fetched after the task starts.", + "litrev.refs.selected": "{selected} / {total} selected", + "litrev.refs.selectAll": "Select all references", + "litrev.refs.referenceColumn": "Reference", + "litrev.refs.statusColumn": "Availability", + "litrev.refs.confirmStart": "Confirm and start", + "litrev.composer.setup": "Send a new message without submitting the current card…", + "litrev.composer.task": "Add requirements anytime; the agent adjusts in real time…", + "litrev.todo.progress": "Executing plan · {done}/{total}", + "litrev.todo.running": "Running", + "litrev.todo.output.downloaded": "The papers are downloaded and verified. I’ll now read the abstracts and full text, then organize evidence for each section.", + "litrev.todo.output.read": "Reading is complete. I’ve grouped the main methods, system differences, and evaluation results. Next I’ll draft the review in LaTeX.", + "litrev.todo.output.drafted": "The LaTeX draft is ready. I’ll now complete the bibliography and normalize the citation format.", + "litrev.todo.output.references": "The references are organized. I’ll generate the PDF and DOCX, then verify that every in-text citation has a matching source.", + "litrev.todo.output.checked": "PDF and DOCX generation and citation checks are complete. The LaTeX source, PDF, and DOCX are ready.", + "litrev.preview.currentProject": "Current project", + "litrev.preview.taskFolder": "Literature review task", + "litrev.preview.projectEmpty": "No files in this project", + "litrev.preview.projectEmptyDetail": "Project files and content generated by the Agent will appear here.", + "litrev.preview.taskEmpty": "This task folder is empty", + "litrev.preview.taskEmptyDetail": "Files downloaded or generated by the Agent will appear in this task folder.", + "litrev.preview.toggleFiles": "Expand or collapse file tree", + "litrev.preview.resizeFiles": "Resize file tree", + "litrev.preview.outputs": "Outputs", + "litrev.preview.files": "Files", + "litrev.preview.openFiles": "Open files", + "litrev.workspace.title": "Workspace", + "litrev.workspace.toggle": "Toggle task files", + "litrev.workspace.resize": "Resize workspace", + "litrev.workspace.emptyTitle": "Nothing to preview yet", + "litrev.workspace.emptyDesc": "Files produced by the task will appear here" }; export type MessageKey = keyof typeof zhCNMessages; diff --git a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts index de05c8cf1..08e0a5662 100644 --- a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts +++ b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts @@ -10,6 +10,7 @@ const allowedSourceFiles = new Set([ "i18n/messages.ts", "lib/nickname.ts", "pages/memory/skill-demo-data.ts", + "pages/literature-review-demo-data.ts", // English ui coverage tests. "dev-agent-preview.tsx" ]); diff --git a/App/frontend/desktop/src/lib/composer-file-reference.ts b/App/frontend/desktop/src/lib/composer-file-reference.ts new file mode 100644 index 000000000..7a961ff8f --- /dev/null +++ b/App/frontend/desktop/src/lib/composer-file-reference.ts @@ -0,0 +1,89 @@ +import type { ComposerContextReference } from "../state/agent-composer-state.js"; + +export const MEMMY_COMPOSER_REFERENCE_MIME = "application/x-memmy-composer-reference+json"; + +export function mergeComposerContextReferences( + current: ComposerContextReference[], + incoming: ComposerContextReference[] +): ComposerContextReference[] { + const next = [...current]; + for (const reference of incoming) { + if (!next.some((item) => item.kind === reference.kind && item.id === reference.id)) { + next.push(reference); + } + } + return next; +} + +export function composerFolderReferenceFromFiles( + files: File[], + resolvePath?: (file: File) => string +): ComposerContextReference | null { + const first = files[0]; + if (!first) return null; + const relativePath = first.webkitRelativePath.replace(/\\/g, "/"); + const rootName = relativePath.split("/")[0] || first.name; + const fullPath = (resolvePath?.(first) || first.name).replace(/\\/g, "/"); + const rootPath = relativePath && fullPath.endsWith(relativePath) + ? `${fullPath.slice(0, -relativePath.length)}${rootName}` + : rootName; + return { + kind: "path", + id: rootPath, + label: `${rootName}/` + }; +} + +export function parsePathReferencesFromComposerContent(content: string): { + content: string; + references: ComposerContextReference[]; +} { + const match = /(?:\r?\n){0,2}\r?\n([\s\S]*?)\r?\n<\/memmy-context>\s*$/.exec(content); + if (!match) return { content, references: [] }; + const references = (match[1] ?? "").split(/\r?\n/).flatMap((line) => { + const prefix = /^- (file|folder): /.exec(line); + const idStart = line.lastIndexOf(" ("); + if (!prefix || idStart <= prefix[0].length || !line.endsWith(")")) return []; + const label = line.slice(prefix[0].length, idStart); + const id = line.slice(idStart + 2, -1); + return label && id ? [{ kind: "path" as const, id, label }] : []; + }); + if (!references.length) return { content, references: [] }; + return { + content: content.slice(0, match.index).trimEnd(), + references + }; +} + +export function writeComposerReferenceDrag( + dataTransfer: DataTransfer, + reference: ComposerContextReference +): void { + dataTransfer.effectAllowed = "copy"; + dataTransfer.setData(MEMMY_COMPOSER_REFERENCE_MIME, JSON.stringify(reference)); + dataTransfer.setData("text/plain", reference.label); +} + +export function readComposerReferenceDrag(dataTransfer: DataTransfer): ComposerContextReference | null { + const payload = dataTransfer.getData(MEMMY_COMPOSER_REFERENCE_MIME); + if (!payload) return null; + try { + const value = JSON.parse(payload) as Partial; + if ( + value.kind === "path" + && typeof value.id === "string" + && value.id.length > 0 + && typeof value.label === "string" + && value.label.length > 0 + ) { + return { kind: value.kind, id: value.id, label: value.label }; + } + } catch { + return null; + } + return null; +} + +export function dataTransferHasComposerReference(dataTransfer: DataTransfer): boolean { + return Array.from(dataTransfer.types).includes(MEMMY_COMPOSER_REFERENCE_MIME); +} diff --git a/App/frontend/desktop/src/lib/file-type.ts b/App/frontend/desktop/src/lib/file-type.ts new file mode 100644 index 000000000..7b6de8e0c --- /dev/null +++ b/App/frontend/desktop/src/lib/file-type.ts @@ -0,0 +1,181 @@ +/** Shared file display classification used across desktop surfaces. */ + +export type FileDisplayKind = + | "pdf" + | "word" + | "spreadsheet" + | "presentation" + | "markdown" + | "text" + | "code" + | "image" + | "video" + | "audio" + | "archive" + | "generic"; + +export interface ResolvedFileType { + kind: FileDisplayKind; + label: string; + shortLabel: string; + extension: string; +} + +const FILE_KIND_BY_EXTENSION: Readonly> = { + ".pdf": "pdf", + ".doc": "word", + ".docx": "word", + ".odt": "word", + ".rtf": "word", + ".xls": "spreadsheet", + ".xlsx": "spreadsheet", + ".ods": "spreadsheet", + ".csv": "spreadsheet", + ".ppt": "presentation", + ".pptx": "presentation", + ".odp": "presentation", + ".key": "presentation", + ".md": "markdown", + ".mdx": "markdown", + ".markdown": "markdown", + ".txt": "text", + ".log": "text", + ".tex": "text", + ".json": "code", + ".jsonl": "code", + ".xml": "code", + ".html": "code", + ".htm": "code", + ".css": "code", + ".scss": "code", + ".less": "code", + ".js": "code", + ".jsx": "code", + ".ts": "code", + ".tsx": "code", + ".py": "code", + ".java": "code", + ".go": "code", + ".rs": "code", + ".sh": "code", + ".sql": "code", + ".yaml": "code", + ".yml": "code", + ".toml": "code", + ".ini": "code", + ".cfg": "code", + ".png": "image", + ".jpg": "image", + ".jpeg": "image", + ".gif": "image", + ".webp": "image", + ".svg": "image", + ".bmp": "image", + ".heic": "image", + ".mp4": "video", + ".webm": "video", + ".mov": "video", + ".m4v": "video", + ".avi": "video", + ".mkv": "video", + ".mp3": "audio", + ".wav": "audio", + ".m4a": "audio", + ".aac": "audio", + ".flac": "audio", + ".ogg": "audio", + ".zip": "archive", + ".rar": "archive", + ".7z": "archive", + ".tar": "archive", + ".gz": "archive", + ".tgz": "archive" +}; + +const FILE_KIND_META: Readonly>> = { + pdf: { kind: "pdf", label: "PDF document", shortLabel: "PDF" }, + word: { kind: "word", label: "Word document", shortLabel: "DOC" }, + spreadsheet: { kind: "spreadsheet", label: "Spreadsheet", shortLabel: "XLS" }, + presentation: { kind: "presentation", label: "Presentation", shortLabel: "PPT" }, + markdown: { kind: "markdown", label: "Markdown document", shortLabel: "MD" }, + text: { kind: "text", label: "Text document", shortLabel: "TXT" }, + code: { kind: "code", label: "Code or configuration file", shortLabel: "CODE" }, + image: { kind: "image", label: "Image file", shortLabel: "IMG" }, + video: { kind: "video", label: "Video file", shortLabel: "VIDEO" }, + audio: { kind: "audio", label: "Audio file", shortLabel: "AUDIO" }, + archive: { kind: "archive", label: "Archive", shortLabel: "ZIP" }, + generic: { kind: "generic", label: "File", shortLabel: "FILE" } +}; + +const EXACT_EXTENSION_LABEL_KINDS = new Set([ + "markdown", + "text", + "code", + "image", + "video", + "audio", + "archive" +]); + +/** Resolve a display type without changing whether the file is uploadable. */ +export function resolveFileType(name: string, mime?: string): ResolvedFileType { + const extension = fileExtension(name); + const kind = fileKindFromMime(mime) ?? FILE_KIND_BY_EXTENSION[extension] ?? "generic"; + return { + ...FILE_KIND_META[kind], + shortLabel: shortLabelForFile(kind, extension), + extension + }; +} + +export function fileExtension(name: string): string { + const clean = String(name ?? "").split(/[?#]/, 1)[0] ?? ""; + const base = clean.split(/[\\/]/).pop() ?? ""; + const index = base.lastIndexOf("."); + return index > 0 && index < base.length - 1 ? base.slice(index).toLowerCase() : ""; +} + +function fileKindFromMime(mime?: string): FileDisplayKind | null { + const normalized = String(mime ?? "").toLowerCase().split(";", 1)[0]?.trim() ?? ""; + if (!normalized) return null; + if (normalized === "application/pdf") return "pdf"; + if (normalized.includes("wordprocessingml") || normalized === "application/msword") return "word"; + if ( + normalized.includes("spreadsheetml") + || normalized.includes("ms-excel") + || normalized === "text/csv" + ) return "spreadsheet"; + if (normalized.includes("presentationml") || normalized.includes("ms-powerpoint")) return "presentation"; + if (normalized.includes("markdown")) return "markdown"; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if (normalized.startsWith("audio/")) return "audio"; + if ( + normalized.includes("zip") + || normalized.includes("rar") + || normalized.includes("7z") + || normalized.includes("tar") + || normalized.includes("gzip") + ) return "archive"; + if ( + normalized.includes("json") + || normalized.includes("xml") + || normalized.includes("yaml") + || normalized.includes("toml") + || normalized === "text/html" + || normalized === "text/css" + || normalized === "application/javascript" + ) return "code"; + if (normalized.startsWith("text/")) return "text"; + return null; +} + +function shortLabelForFile(kind: FileDisplayKind, extension: string): string { + const extensionLabel = extension.replace(/^\./, "").toUpperCase(); + if (kind === "spreadsheet" && extension === ".csv") return "CSV"; + if (kind === "presentation" && extension === ".key") return "KEY"; + if (EXACT_EXTENSION_LABEL_KINDS.has(kind) && extensionLabel && extensionLabel.length <= 5) { + return extensionLabel; + } + return FILE_KIND_META[kind].shortLabel; +} diff --git a/App/frontend/desktop/src/lib/literature-source-files.ts b/App/frontend/desktop/src/lib/literature-source-files.ts new file mode 100644 index 000000000..efb8676ab --- /dev/null +++ b/App/frontend/desktop/src/lib/literature-source-files.ts @@ -0,0 +1,31 @@ +export const LITERATURE_SOURCE_ACCEPT = ".pdf,.docx,.doc,.txt,.md"; + +const SUPPORTED_EXTENSIONS = new Set(["pdf", "docx", "doc", "txt", "md"]); + +export interface LiteratureSourceBatchAssessment { + accepted: T[]; + unsupportedCount: number; +} + +export function isSupportedLiteratureSourceName(name: string): boolean { + const normalized = name.trim().toLowerCase(); + const extensionStart = normalized.lastIndexOf("."); + if (extensionStart <= 0 || extensionStart === normalized.length - 1) return false; + return SUPPORTED_EXTENSIONS.has(normalized.slice(extensionStart + 1)); +} + +export function assessLiteratureSourceBatch( + files: T[] +): LiteratureSourceBatchAssessment { + const accepted = files.filter((file) => isSupportedLiteratureSourceName(file.name)); + const unsupportedCount = files.length - accepted.length; + return { accepted, unsupportedCount }; +} + +export function formatLiteratureSourceSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kilobytes = bytes / 1024; + if (kilobytes < 1024) return `${kilobytes.toFixed(kilobytes >= 10 ? 0 : 1)} KB`; + const megabytes = kilobytes / 1024; + return `${megabytes.toFixed(megabytes >= 10 ? 0 : 1)} MB`; +} diff --git a/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts new file mode 100644 index 000000000..4d809b500 --- /dev/null +++ b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + composerFolderReferenceFromFiles, + MEMMY_COMPOSER_REFERENCE_MIME, + mergeComposerContextReferences, + parsePathReferencesFromComposerContent, + readComposerReferenceDrag, + writeComposerReferenceDrag +} from "../composer-file-reference.js"; + +function fakeDataTransfer(): DataTransfer { + const values = new Map(); + return { + effectAllowed: "uninitialized", + setData(type: string, value: string) { + values.set(type, value); + }, + getData(type: string) { + return values.get(type) ?? ""; + }, + get types() { + return [...values.keys()]; + } + } as DataTransfer; +} + +describe("composer file references", () => { + it("round-trips an internal drag payload", () => { + const transfer = fakeDataTransfer(); + const reference = { kind: "path" as const, id: "references/paper.pdf", label: "paper.pdf" }; + + writeComposerReferenceDrag(transfer, reference); + + expect(transfer.getData(MEMMY_COMPOSER_REFERENCE_MIME)).toContain("references/paper.pdf"); + expect(readComposerReferenceDrag(transfer)).toEqual(reference); + expect(transfer.effectAllowed).toBe("copy"); + }); + + it("deduplicates references by kind and id", () => { + const first = { kind: "path" as const, id: "paper.pdf", label: "paper.pdf" }; + const folder = { kind: "path" as const, id: "研究资料", label: "研究资料/" }; + + expect(mergeComposerContextReferences([first], [first, folder])).toEqual([first, folder]); + }); + + it("collapses a directory selection into one folder reference", () => { + const file = new File(["paper"], "paper.pdf"); + Object.defineProperty(file, "webkitRelativePath", { value: "Papers/2026/paper.pdf" }); + + expect(composerFolderReferenceFromFiles( + [file], + () => "/Users/memmy/Papers/2026/paper.pdf" + )).toEqual({ + kind: "path", + id: "/Users/memmy/Papers", + label: "Papers/" + }); + }); + + it("restores sent references from canonical thread content", () => { + expect(parsePathReferencesFromComposerContent( + "请结合这些资料\n\n\n" + + "- file: paper.pdf (/Users/memmy/paper.pdf)\n" + + "- folder: Papers/ (/Users/memmy/Papers)\n" + + "" + )).toEqual({ + content: "请结合这些资料", + references: [ + { kind: "path", id: "/Users/memmy/paper.pdf", label: "paper.pdf" }, + { kind: "path", id: "/Users/memmy/Papers", label: "Papers/" } + ] + }); + }); +}); diff --git a/App/frontend/desktop/src/lib/tests/literature-source-files.test.ts b/App/frontend/desktop/src/lib/tests/literature-source-files.test.ts new file mode 100644 index 000000000..c77a9456d --- /dev/null +++ b/App/frontend/desktop/src/lib/tests/literature-source-files.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + assessLiteratureSourceBatch, + isSupportedLiteratureSourceName +} from "../literature-source-files.js"; + +describe("literature source files", () => { + it("only accepts PDF, DOCX, DOC, TXT, and Markdown files", () => { + expect(["paper.PDF", "notes.docx", "draft.doc", "readme.txt", "research.md"].every(isSupportedLiteratureSourceName)).toBe(true); + expect(["data.csv", "image.png", "paper.tex", "pdf"].some(isSupportedLiteratureSourceName)).toBe(false); + }); + + it("keeps every supported file in the selected local scope", () => { + const files = Array.from({ length: 250 }, (_, index) => ({ + name: `paper-${index}.pdf`, + size: 100 + })); + const result = assessLiteratureSourceBatch(files); + + expect(result.accepted).toHaveLength(250); + expect(result.unsupportedCount).toBe(0); + }); +}); diff --git a/App/frontend/desktop/src/main.tsx b/App/frontend/desktop/src/main.tsx index 2304580b2..0d1b91a39 100644 --- a/App/frontend/desktop/src/main.tsx +++ b/App/frontend/desktop/src/main.tsx @@ -6,6 +6,7 @@ import { initGtag } from "./analytics/gtag-init.js"; import { NicknameModal } from "./components/nickname-modal.js"; import { I18nProvider } from "./i18n/i18n-provider.js"; import { randomNickname } from "./lib/nickname.js"; +import { LiteratureReviewPage } from "./pages/literature-review-page.js"; import { MemoryPage } from "./pages/memory-page.js"; import { MemoryPluginConflictModal } from "./pages/memory-plugin-conflict-modal.js"; import { StartupScreen } from "./pages/startup-screen.js"; @@ -73,6 +74,15 @@ function MemorySkillsPreview() { ); } +/** Design-complete literature-review mock: no backend required. */ +function LiteratureReviewPreview() { + return ( + + + + ); +} + const root = document.getElementById("root"); if (!root) { @@ -95,6 +105,8 @@ createRoot(root).render( ) : previewMode === "memory-skills" ? ( + ) : previewMode === "literature-review" ? ( + ) : ( )} diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index f0a6caf7a..a50378505 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -173,11 +173,8 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { } export function slashQueryFromInput(input: string): string | null { - if (!input.startsWith("/")) { - return null; - } - const token = input.slice(1); - return /\s/.test(token) ? null : token.toLowerCase(); + const match = /(?:^|\s)\/([^\s/]*)$/.exec(input); + return match ? (match[1] ?? "").toLowerCase() : null; } export function buildVisibleSlashCommands( diff --git a/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx b/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx index 6e9c81fae..8d7d2afdf 100644 --- a/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx +++ b/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx @@ -1,17 +1,10 @@ import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from "react"; -import { BookText, FileSpreadsheet, FileText, NotepadText, Presentation, X, type LucideIcon } from "lucide-react"; +import { X } from "lucide-react"; +import { FileTypeIcon } from "../components/file-type-icon.js"; +import { resolveFileType, type FileDisplayKind, type ResolvedFileType } from "../lib/file-type.js"; -export type AgentFileDisplayKind = "pdf" | "docx" | "xlsx" | "pptx" | "file"; - -export interface AgentFileVisual { - kind: AgentFileDisplayKind; - label: string; - shortLabel: "PDF" | "DOC" | "XLS" | "PPT" | "FILE"; - typeLabel: string; - icon: LucideIcon; - tileClassName: string; - labelClassName: string; -} +export type AgentFileDisplayKind = FileDisplayKind; +export type AgentFileVisual = ResolvedFileType; export interface AgentAttachmentNameParts { displayName: string; @@ -22,12 +15,14 @@ export interface AgentAttachmentCardProps { kind: "image" | "file"; name: string; mime?: string; + filePath?: string; previewUrl?: string; subline?: string; busyLabel?: string; title?: string; removable?: boolean; removeLabel?: string; + leading?: ReactNode; thumbnailOverlay?: ReactNode; onRemove?: () => void; onClick?: () => void; @@ -38,39 +33,8 @@ export interface AgentAttachmentCardProps { align?: "left" | "right"; } -const TEXT_FILE_EXTENSIONS = new Set([ - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".html", - ".htm", - ".log", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", -]); - export function resolveAgentFileVisual(name: string, mime?: string): AgentFileVisual { - const extension = fileExtension(name); - const normalizedMime = String(mime ?? "").toLowerCase(); - const kind: AgentFileDisplayKind = - extension === ".pdf" || normalizedMime === "application/pdf" - ? "pdf" - : extension === ".docx" || normalizedMime.includes("wordprocessingml") - ? "docx" - : extension === ".xlsx" || normalizedMime.includes("spreadsheetml") - ? "xlsx" - : extension === ".pptx" || normalizedMime.includes("presentationml") - ? "pptx" - : TEXT_FILE_EXTENSIONS.has(extension) - ? "file" - : "file"; - - return visualForKind(kind, attachmentTypeLabel(name)); + return resolveFileType(name, mime); } export function splitAgentAttachmentName(name: string, fallbackExtension?: string): AgentAttachmentNameParts { @@ -88,23 +52,17 @@ export function splitAgentAttachmentName(name: string, fallbackExtension?: strin export function AgentFileIconTile(props: { name: string; mime?: string; + filePath?: string; size?: "sm" | "md"; }) { - const visual = resolveAgentFileVisual(props.name, props.mime); - const Icon = visual.icon; - const sizeClassName = props.size === "md" ? "agent-attachment-card__file-tile--md" : "agent-attachment-card__file-tile--sm"; - const iconSize = props.size === "md" ? 16 : 14; return ( - - - - {visual.shortLabel} - - + ); } @@ -126,7 +84,7 @@ export function AgentAttachmentCard(props: AgentAttachmentCardProps) { ].filter(Boolean).join(" "); const mainContent = ( <> - {props.kind === "image" ? ( + {props.leading ? props.leading : props.kind === "image" ? ( {props.previewUrl ? ( ) : ( - + )} @@ -235,72 +193,6 @@ export function AgentAttachmentCard(props: AgentAttachmentCardProps) { ); } -function visualForKind(kind: AgentFileDisplayKind, typeLabel: string): AgentFileVisual { - switch (kind) { - case "pdf": - return { - kind, - label: "PDF file", - shortLabel: "PDF", - typeLabel, - icon: FileText, - tileClassName: "agent-attachment-card__file-tile--pdf", - labelClassName: "agent-attachment-card__file-label" - }; - case "docx": - return { - kind, - label: "Word document", - shortLabel: "DOC", - typeLabel, - icon: BookText, - tileClassName: "agent-attachment-card__file-tile--docx", - labelClassName: "agent-attachment-card__file-label" - }; - case "xlsx": - return { - kind, - label: "Spreadsheet file", - shortLabel: "XLS", - typeLabel, - icon: FileSpreadsheet, - tileClassName: "agent-attachment-card__file-tile--xlsx", - labelClassName: "agent-attachment-card__file-label" - }; - case "pptx": - return { - kind, - label: "Presentation file", - shortLabel: "PPT", - typeLabel, - icon: Presentation, - tileClassName: "agent-attachment-card__file-tile--pptx", - labelClassName: "agent-attachment-card__file-label" - }; - case "file": - default: - return { - kind: "file", - label: "File attachment", - shortLabel: "FILE", - typeLabel, - icon: NotepadText, - tileClassName: "agent-attachment-card__file-tile--file", - labelClassName: "agent-attachment-card__file-label" - }; - } -} - -function attachmentTypeLabel(name: string): string { - return fileExtension(name).replace(/^\./, "").slice(0, 4).toUpperCase() || "FILE"; -} - -function fileExtension(name: string): string { - const base = basenameWithoutQuery(name); - const index = base.lastIndexOf("."); - return index > 0 ? base.slice(index).toLowerCase() : ""; -} - function basenameWithoutQuery(name: string): string { const withoutQuery = (name || "").split(/[?#]/)[0] ?? name; return withoutQuery.split(/[\\/]/).pop() || withoutQuery || ""; diff --git a/App/frontend/desktop/src/pages/agent-message-content.tsx b/App/frontend/desktop/src/pages/agent-message-content.tsx index 40bd1df5d..0a2cbbb76 100644 --- a/App/frontend/desktop/src/pages/agent-message-content.tsx +++ b/App/frontend/desktop/src/pages/agent-message-content.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; -import { ExternalLink, FileText, Image as ImageIcon } from "lucide-react"; +import { ExternalLink, Image as ImageIcon } from "lucide-react"; import ReactMarkdown, { type Components } from "react-markdown"; import { Prism as SyntaxHighlighter, type SyntaxHighlighterProps } from "react-syntax-highlighter"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; @@ -9,6 +9,7 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import type { MemmyAgentClient, ResolvedAgentArtifact } from "../api/memmy-agent-client.js"; +import { FileTypeIcon } from "../components/file-type-icon.js"; import { useTranslation } from "../i18n/use-translation.js"; export type AgentArtifactClient = { @@ -458,7 +459,7 @@ function FileReferenceChip(props: { disabled={actionState === "working" || (!resolved && !failed)} className={`agent-message-content__file-chip inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-tag border px-2 py-1 align-baseline text-xs disabled:cursor-wait disabled:opacity-70 ${failed || actionState === "error" ? "border-status-error/30 bg-status-error/5 text-status-error" : "border-border-stone/40 bg-canvas-oat/70 text-text-ink/65 hover:text-action-sky"}`} > - + {actionState === "working" ? t("agent.attachment.opening") : label} {actionState === "error" ? ( diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index cbf220c7b..2855d23c4 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -38,6 +38,7 @@ import { type AttachmentDownloadStarter, type AttachmentCopyTarget, } from "./agent-message-content.js"; +import { HomeContextChips } from "./home-composer-quick-actions.js"; import { Memmy } from "../components/mascot/memmy.js"; import { Tooltip } from "../components/tooltip.js"; import { useTranslation } from "../i18n/use-translation.js"; @@ -412,6 +413,9 @@ const SingleMessage = memo(function SingleMessage(props: SingleMessageProps) { return (
+ {message.contextReferences?.length ? ( + + ) : null} {message.media?.length ? ( ) : null} diff --git a/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx new file mode 100644 index 000000000..2b9d451f6 --- /dev/null +++ b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx @@ -0,0 +1,308 @@ +/** + * Composer quick actions for the new-task screen (design-complete mock). + * + * Implements the prototype's composer affordances: attach (+) and capability + * (/) triggers with popovers anchored under each button, plus + * selected context chips in the composer toolbar. + */ +import { + useEffect, + useRef, + useState, + type ChangeEventHandler, + type ClipboardEventHandler, + type CSSProperties, + type FormEventHandler, + type KeyboardEventHandler, + type ReactNode, + type Ref +} from "react"; +import { Plus, SquareSlash } from "lucide-react"; +import { FileTypeIcon, FolderTypeIcon } from "../components/file-type-icon.js"; +import { Tooltip } from "../components/tooltip.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import type { ComposerContextReference } from "../state/agent-composer-state.js"; +import { AgentAttachmentCard } from "./agent-file-attachment-chip.js"; + +export type ComposerContextChip = ComposerContextReference; + +interface ComposerHighlightSegment { + text: string; + command: boolean; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Splits selected slash commands from surrounding text without changing textarea layout. */ +export function composerHighlightSegments( + input: string, + highlightedCommands: readonly string[] +): ComposerHighlightSegment[] { + const commands = [...new Set(highlightedCommands.filter(Boolean))] + .sort((left, right) => right.length - left.length); + if (!commands.length || !input) return [{ text: input, command: false }]; + + const commandPattern = commands.map(escapeRegExp).join("|"); + const matcher = new RegExp(`(^|\\s)(${commandPattern})(?=\\s|$)`, "gi"); + const segments: ComposerHighlightSegment[] = []; + let cursor = 0; + + for (const match of input.matchAll(matcher)) { + const matchIndex = match.index ?? 0; + const leadingSpace = match[1] ?? ""; + const command = match[2] ?? ""; + const commandStart = matchIndex + leadingSpace.length; + if (commandStart > cursor) { + segments.push({ text: input.slice(cursor, commandStart), command: false }); + } + segments.push({ text: command, command: true }); + cursor = commandStart + command.length; + } + if (cursor < input.length) { + segments.push({ text: input.slice(cursor), command: false }); + } + return segments.length ? segments : [{ text: input, command: false }]; +} + +export function removeHighlightedCommandAtCaret( + input: string, + highlightedCommands: readonly string[], + caret: number, + key: "Backspace" | "Delete" +): { value: string; caret: number } | null { + const commands = [...new Set(highlightedCommands.filter(Boolean))] + .sort((left, right) => right.length - left.length); + if (!commands.length) return null; + const matcher = new RegExp( + `(^|\\s)(${commands.map(escapeRegExp).join("|")})(?=\\s|$)`, + "gi" + ); + for (const match of input.matchAll(matcher)) { + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + const end = start + (match[2]?.length ?? 0); + const backspaceMatch = key === "Backspace" + && (caret === end || (caret === end + 1 && /\s/.test(input[end] ?? ""))); + const deleteMatch = key === "Delete" && caret === start; + if (!backspaceMatch && !deleteMatch) continue; + + const removeEnd = /\s/.test(input[end] ?? "") ? end + 1 : end; + const removeStart = removeEnd === end && start > 0 && /\s/.test(input[start - 1] ?? "") + ? start - 1 + : start; + return { + value: `${input.slice(0, removeStart)}${input.slice(removeEnd)}`, + caret: removeStart + }; + } + return null; +} + +/** Native textarea with an aligned backdrop that paints selected commands as inline chips. */ +export function ComposerHighlightedTextarea(props: { + value: string; + className?: string; + style?: CSSProperties; + placeholder?: string; + rows?: number; + highlightedCommands?: readonly string[]; + textareaRef?: Ref; + onChange?: ChangeEventHandler; + onKeyDown?: KeyboardEventHandler; + onPaste?: ClipboardEventHandler; + onInput?: FormEventHandler; +}) { + const [scrollTop, setScrollTop] = useState(0); + const segments = composerHighlightSegments(props.value, props.highlightedCommands ?? []); + + function handleKeyDown(event: Parameters>[0]) { + if ( + (event.key === "Backspace" || event.key === "Delete") + && event.currentTarget.selectionStart === event.currentTarget.selectionEnd + && !event.nativeEvent.isComposing + ) { + const edit = removeHighlightedCommandAtCaret( + props.value, + props.highlightedCommands ?? [], + event.currentTarget.selectionStart, + event.key + ); + if (edit && props.onChange) { + event.preventDefault(); + const textarea = event.currentTarget; + const targetProxy = new Proxy(textarea, { + get(target, property) { + if (property === "value") return edit.value; + if (property === "selectionStart" || property === "selectionEnd") return edit.caret; + const value = Reflect.get(target, property, target); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(target) + : value; + } + }) as HTMLTextAreaElement; + props.onChange({ + ...event, + target: targetProxy, + currentTarget: targetProxy + }); + window.requestAnimationFrame(() => { + textarea.setSelectionRange(edit.caret, edit.caret); + }); + return; + } + } + props.onKeyDown?.(event); + } + + return ( +
+ + "); + expect(html).toContain("分配一个任务或提问任何问题..."); + }); + + it("keeps unselected slash text editable instead of turning it into a capability chip", () => { + const html = renderToString( + + ); + + expect(html).not.toContain("composer-slash-chip"); + expect(html).toContain("/AI Memory"); + }); + + it("highlights and removes a selected capability at an inline caret position", () => { + expect(composerHighlightSegments( + "前文 /literature-review 后文", + ["/literature-review"] + )).toEqual([ + { text: "前文 ", command: false }, + { text: "/literature-review", command: true }, + { text: " 后文", command: false } + ]); + expect(removeHighlightedCommandAtCaret( + "前文 /literature-review 后文", + ["/literature-review"], + 21, + "Backspace" + )).toEqual({ value: "前文 后文", caret: 3 }); + }); + it("renders the first-phase agent input controls", () => { const html = renderToString( @@ -70,9 +408,14 @@ describe("HomePage", () => { ); expect(html).toContain("分配一个任务或提问任何问题..."); - expect(html).toContain("添加图片和文件"); + expect(html).toContain("添加资料"); + expect(html).not.toContain('aria-label="引用"'); + expect(html).toContain("能力"); expect(html).toContain("语音输入"); expect(html).toContain("发送"); + expect(html).toContain("帮我写一篇关于 AI Memory 研究的文献综述"); + expect(html).toContain("帮我总结一下本周的工作"); + expect(html).toContain("梳理我最近的一个任务,并列出可行的待办"); expect(html).toContain("Agent 正在连接"); expect(html).not.toContain('aria-haspopup="menu"'); expect(html).toContain('class="home-project-picker__trigger"'); @@ -80,7 +423,7 @@ describe("HomePage", () => { expect(html).toContain(`accept="${AGENT_MEDIA_ACCEPT}"`); expect(html).toContain("hidden"); expect(html).toContain('class="hidden"'); - expect(html).toContain('data-icon="plus"'); + expect(html).toContain("lucide-plus"); expect(html).toContain('data-icon="mic"'); expect(html).toContain('data-icon="send"'); expect(html).not.toContain("添加照片和文件"); @@ -88,7 +431,7 @@ describe("HomePage", () => { expect(html).not.toContain('data-icon="image-plus"'); expect(html).not.toContain('data-icon="pause"'); expect(html).toContain("内容由 AI 生成,请仔细甄别"); - expect(html).toContain("text-center text-[11px] text-text-ink/40 mt-4"); + expect(html).toContain("text-center text-[11px] text-text-ink/40 mt-3"); expect(html).not.toContain("未选择任何文件"); }); @@ -106,7 +449,7 @@ describe("HomePage", () => { source.indexOf(" useEffect(() => {\n if (!clients?.memmyAgent)") ); const updateComposerInputBlock = source.slice( - source.indexOf("function updateComposerInput(value: string)"), + source.indexOf("function updateComposerInput(value: string,"), source.indexOf(" /**\n * 自动收缩或展开输入框高度。") ); @@ -128,11 +471,20 @@ describe("HomePage", () => { expect(updateComposerInputBlock).toContain("loadSlashCommands({ resetAttempts: true });"); }); - it("keeps slash menu rendering and command panels on their existing boundaries", () => { + it("anchors typed slash menus at the caret and button-triggered menus at the button", () => { const source = readFileSync(homePageSourcePath, "utf8"); expect(source).toContain("const slashMenuOpen = filteredSlashCommands.length > 0;"); - expect(source.match(/\{slashMenuOpen && \(/g)).toHaveLength(2); + expect(source).toContain("function ComposerCaretMenu(props:"); + expect(source).toContain('mirror.style.whiteSpace = "pre-wrap";'); + expect(source).toContain("const caretTop = caretMarker.offsetTop;"); + expect(source).not.toContain("context?.measureText(currentLine)"); + expect(source.match(/\{slashMenuOpen && !slashPickerOpen \? \(/g)).toHaveLength(2); + expect(source).toContain("slashMenu={slashMenuOpen && slashPickerOpen ? ("); + expect(source).toContain("{slashMenuOpen && slashPickerOpen && ("); + expect(source).not.toContain('slashPickerOpen || /^\\s*\\//.test(input)'); + expect(source).not.toContain("referenceMenuOpen"); + expect(source).not.toContain("referencePickerOpen"); expect(source).toContain("const [lastCompactionPanel, setLastCompactionPanel] = useState({ open: false });"); expect(source).toContain("const lastCompactionSlashCommand: SlashCommandPaletteItem = {"); expect(source).toContain('command: "/last-compaction"'); @@ -367,6 +719,15 @@ describe("HomePage", () => { expect(sendBlock).toContain("if (runExactLocalSlashCommand(input))"); expect(sendBlock.indexOf("runExactLocalSlashCommand(input)")).toBeLessThan(sendBlock.indexOf("submitAgentComposerMessage({")); + expect(localSlashBlock).toContain("LITREV_CONTEXT_STORAGE_KEY"); + expect(localSlashBlock).toContain("LITREV_SOURCE_INPUT_STORAGE_KEY"); + expect(localSlashBlock).toContain("sourceInput"); + expect(localSlashBlock).toContain('/(?:^|\\s)\\/literature-review(?=\\s|$)/i'); + expect(localSlashBlock).toContain("JSON.stringify(contextChips)"); + expect(localSlashBlock).toContain("composerContextReferencesUpdated(chatScopeKey, [])"); + expect(localSlashBlock).toContain('draftTarget.kind === "project"'); + expect(localSlashBlock).toContain("LITREV_PROJECT_CONTEXT_STORAGE_KEY"); + expect(localSlashBlock).toContain("removeItem(LITREV_PROJECT_CONTEXT_STORAGE_KEY)"); expect(localSlashBlock).toContain("if (pendingAttachments.length > 0) return false;"); expect(localSlashBlock).toContain('normalized === "/last-compaction"'); expect(localSlashBlock).toContain("requestLastCompactionPanel();"); @@ -788,14 +1149,14 @@ describe("HomePage", () => { expect(focusInput).toHaveBeenCalledTimes(1); }); - it("opens the system media picker directly from the plus button without rendering a floating media menu", () => { + it("offers file and folder choices from the composer plus button", () => { const source = readFileSync(homePageSourcePath, "utf8"); - expect(source).toContain("onClick={openMediaFilePicker}"); - expect(source).not.toContain("function ComposerMediaMenu"); - expect(source).not.toContain("setMediaMenuOpen"); + expect(source).toContain("openMediaFilePicker();"); + expect(source).toContain("openFolderPicker();"); + expect(source).toContain("agent-composer-attach-menu__popover"); expect(source).not.toContain("aria-haspopup=\"menu\""); - expect(source).not.toContain("role=\"menuitem\""); + expect(source).toContain("role=\"menuitem\""); }); it("renders composer media previews as compact thumbnail and file chips", () => { @@ -840,32 +1201,36 @@ describe("HomePage", () => { expect(html).toContain(">table<"); expect(html).toContain(">data<"); expect(html).toContain(">payload<"); - expect(html).toContain(">PDF<"); - expect(html).toContain(">DOC<"); - expect(html).toContain(">XLS<"); - expect(html).toContain(">PPT<"); - expect(html).toContain(">FILE<"); + expect(html).toContain("file-type-icon__paper"); + expect(html).toContain("file-type-icon__glyph"); + expect(html).toContain("file-type-icon__format-label"); + expect(html).toContain(">PDF"); + expect(html).toContain(">DOC"); + expect(html).toContain(">XLS"); + expect(html).toContain(">PPT"); expect(compactHtml).toContain("XLSX · 2.0 KB"); expect(compactHtml).toContain("PPTX · 1.5 KB"); expect(compactHtml).toContain("TXT · 512 B"); expect(compactHtml).toContain("CSV · 768 B"); expect(compactHtml).toContain("JSON · 1.0 KB"); expect(compactHtml).toContain("XML · 640 B"); - expect(html).toContain('data-testid="agent-file-icon-pdf"'); - expect(html).toContain('data-testid="agent-file-icon-docx"'); - expect(html).toContain('data-testid="agent-file-icon-xlsx"'); - expect(html).toContain('data-testid="agent-file-icon-pptx"'); - expect(html).toContain('data-testid="agent-file-icon-file"'); - expect(html).toContain("agent-attachment-card__file-tile--pdf"); - expect(html).toContain("agent-attachment-card__file-tile--docx"); - expect(html).toContain("agent-attachment-card__file-tile--xlsx"); - expect(html).toContain("agent-attachment-card__file-tile--pptx"); - expect(html).toContain("agent-attachment-card__file-tile--file"); - expect(html).toContain('aria-label="PDF file"'); + expect(html).toContain('data-testid="file-type-icon-pdf"'); + expect(html).toContain('data-testid="file-type-icon-word"'); + expect(html).toContain('data-testid="file-type-icon-spreadsheet"'); + expect(html).toContain('data-testid="file-type-icon-presentation"'); + expect(html).toContain('data-testid="file-type-icon-text"'); + expect(html).toContain('data-testid="file-type-icon-code"'); + expect(html).toContain("file-type-icon--pdf"); + expect(html).toContain("file-type-icon--word"); + expect(html).toContain("file-type-icon--spreadsheet"); + expect(html).toContain("file-type-icon--presentation"); + expect(html).toContain("file-type-icon--text"); + expect(html).toContain("file-type-icon--code"); + expect(html).toContain('aria-label="PDF document"'); expect(html).toContain('aria-label="Word document"'); - expect(html).toContain('aria-label="Spreadsheet file"'); - expect(html).toContain('aria-label="Presentation file"'); - expect(html).toContain('aria-label="File attachment"'); + expect(html).toContain('aria-label="Spreadsheet"'); + expect(html).toContain('aria-label="Presentation"'); + expect(html).toContain('aria-label="Text document"'); expect(html).not.toContain("absolute -right-1 -bottom-1"); expect(html).not.toContain('data-testid="composer-file-kind-'); expect(compactHtml).toContain("PNG · 2.0 KB"); diff --git a/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts b/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts new file mode 100644 index 000000000..e020a0ef9 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + moveOutlineItem, + type LitrevOutlineItem +} from "../literature-review-demo-data.js"; + +const outline: LitrevOutlineItem[] = [ + { id: "a", text: "A", level: 0 }, + { id: "a-1", text: "A.1", level: 1 }, + { id: "b", text: "B", level: 0 }, + { id: "c", text: "C", level: 0 } +]; + +describe("literature review outline hierarchy", () => { + it("changes hierarchy even when dropped at the same position", () => { + const moved = moveOutlineItem(outline, 2, 2, 1); + + expect(moved.map((item) => [item.id, item.level])).toEqual([ + ["a", 0], + ["a-1", 1], + ["b", 1], + ["c", 0] + ]); + }); + + it("promotes a level-two item when dropped at the same position", () => { + const moved = moveOutlineItem(outline, 1, 1, 0); + + expect(moved.map((item) => [item.id, item.level])).toEqual([ + ["a", 0], + ["a-1", 0], + ["b", 0], + ["c", 0] + ]); + }); + + it("prevents an orphan level-two item at the beginning", () => { + const moved = moveOutlineItem(outline, 2, 0, 1); + + expect(moved[0]).toMatchObject({ id: "b", level: 0 }); + }); + + it("moves existing children together with their level-one parent", () => { + const moved = moveOutlineItem(outline, 0, 3, 0); + + expect(moved.map((item) => item.id)).toEqual(["b", "c", "a", "a-1"]); + expect(moved.at(-1)?.level).toBe(1); + }); +}); diff --git a/App/frontend/desktop/src/pages/tools-page.tsx b/App/frontend/desktop/src/pages/tools-page.tsx index b590b808f..17d7dd637 100644 --- a/App/frontend/desktop/src/pages/tools-page.tsx +++ b/App/frontend/desktop/src/pages/tools-page.tsx @@ -134,11 +134,11 @@ export function ToolsPageView(props: ToolsPageViewProps) { return (
-
+
-

{t("tools.title")}

-

{t("tools.subtitle")}

+

{t("tools.title")}

+

{t("tools.subtitle")}

diff --git a/App/frontend/desktop/src/state/agent-chat-slice.ts b/App/frontend/desktop/src/state/agent-chat-slice.ts index 9a67587db..464cce134 100644 --- a/App/frontend/desktop/src/state/agent-chat-slice.ts +++ b/App/frontend/desktop/src/state/agent-chat-slice.ts @@ -18,7 +18,8 @@ import type { WebuiSessionTarget } from "../api/memmy-agent-client.js"; import { chatIdToSessionKey } from "../api/memmy-agent-client.js"; -import type { PendingAttachment } from "./agent-composer-state.js"; +import { parsePathReferencesFromComposerContent } from "../lib/composer-file-reference.js"; +import type { ComposerContextReference, PendingAttachment } from "./agent-composer-state.js"; import { mergeFileEdits, mergeToolProgressEvents, @@ -105,6 +106,7 @@ export interface AgentChatMessage { reasoning?: string; reasoningStreaming?: boolean; media?: AgentChatMediaAttachment[]; + contextReferences?: ComposerContextReference[]; traces?: string[]; toolEvents?: AgentToolProgressEvent[]; fileEdits?: AgentFileEdit[]; @@ -181,6 +183,7 @@ export interface AgentState { goalState: AgentGoalState | null; composerDraftsByScope: Record; composerPendingAttachmentsByScope: Record; + composerContextReferencesByScope: Record; draftTargetsByScope: Record; draftTargetRevisionByScope: Record; messageSendInFlightByScope: Record; @@ -228,9 +231,10 @@ export type AgentAction = | { type: "agent/blankDraftReopened" } | { type: "agent/newChatCreated"; chatId: string } | { type: "agent/transientSendFailed"; chatId: string } - | { type: "agent/userMessageQueued"; chatId: string; content: string; media?: AgentChatMediaAttachment[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget } + | { type: "agent/userMessageQueued"; chatId: string; content: string; media?: AgentChatMediaAttachment[]; contextReferences?: ComposerContextReference[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget } | { type: "agent/composerDraftUpdated"; scopeKey: string; value: string } | { type: "agent/composerPendingAttachmentsUpdated"; scopeKey: string; attachments: PendingAttachment[] } + | { type: "agent/composerContextReferencesUpdated"; scopeKey: string; references: ComposerContextReference[] } | { type: "agent/draftTargetUpdated"; scopeKey: string; target: WebuiSessionTarget } | { type: "agent/messageSendLockUpdated"; scopeKey: string; clientRequestId: string | null } | { type: "agent/tasksMarkedRead"; chatIds: string[] } @@ -312,6 +316,7 @@ export const initialAgentState: AgentState = { goalState: null, composerDraftsByScope: {}, composerPendingAttachmentsByScope: {}, + composerContextReferencesByScope: {}, draftTargetsByScope: {}, draftTargetRevisionByScope: {}, messageSendInFlightByScope: {}, @@ -459,6 +464,8 @@ export function agentReducer(state: AgentState, action: AgentAction): AgentState return updateComposerDraft(state, action.scopeKey, action.value); case "agent/composerPendingAttachmentsUpdated": return updateComposerPendingAttachments(state, action.scopeKey, action.attachments); + case "agent/composerContextReferencesUpdated": + return updateComposerContextReferences(state, action.scopeKey, action.references); case "agent/draftTargetUpdated": return { ...state, @@ -581,7 +588,8 @@ function queueOptimisticUserMessage( role: "user", content: action.content, createdAt: now, - ...(action.media?.length ? { media: action.media } : {}) + ...(action.media?.length ? { media: action.media } : {}), + ...(action.contextReferences?.length ? { contextReferences: action.contextReferences } : {}) }; const messages = [...existingMessages, message]; const messagesByChatId = { ...nextState.messagesByChatId, [action.chatId]: messages }; @@ -879,23 +887,51 @@ function updateComposerPendingAttachments(state: AgentState, scopeKey: string, a }; } +function updateComposerContextReferences( + state: AgentState, + scopeKey: string, + references: ComposerContextReference[] +): AgentState { + if (!references.length) { + if (!(scopeKey in state.composerContextReferencesByScope)) { + return state; + } + const composerContextReferencesByScope = { ...state.composerContextReferencesByScope }; + delete composerContextReferencesByScope[scopeKey]; + return { ...state, composerContextReferencesByScope }; + } + if (state.composerContextReferencesByScope[scopeKey] === references) { + return state; + } + return { + ...state, + composerContextReferencesByScope: { + ...state.composerContextReferencesByScope, + [scopeKey]: references + } + }; +} + function clearComposerScope(state: AgentState, scopeKey: string): AgentState { const hasDraft = scopeKey in state.composerDraftsByScope; const hasPendingAttachments = scopeKey in state.composerPendingAttachmentsByScope; + const hasContextReferences = scopeKey in state.composerContextReferencesByScope; const hasTarget = scopeKey in state.draftTargetsByScope; const hasTargetRevision = scopeKey in state.draftTargetRevisionByScope; const hasSendLock = scopeKey in state.messageSendInFlightByScope; - if (!hasDraft && !hasPendingAttachments && !hasTarget && !hasTargetRevision && !hasSendLock) { + if (!hasDraft && !hasPendingAttachments && !hasContextReferences && !hasTarget && !hasTargetRevision && !hasSendLock) { return state; } const composerDraftsByScope = { ...state.composerDraftsByScope }; const composerPendingAttachmentsByScope = { ...state.composerPendingAttachmentsByScope }; + const composerContextReferencesByScope = { ...state.composerContextReferencesByScope }; const draftTargetsByScope = { ...state.draftTargetsByScope }; const draftTargetRevisionByScope = { ...state.draftTargetRevisionByScope }; const messageSendInFlightByScope = { ...state.messageSendInFlightByScope }; delete composerDraftsByScope[scopeKey]; delete composerPendingAttachmentsByScope[scopeKey]; + delete composerContextReferencesByScope[scopeKey]; delete draftTargetsByScope[scopeKey]; delete draftTargetRevisionByScope[scopeKey]; delete messageSendInFlightByScope[scopeKey]; @@ -903,6 +939,7 @@ function clearComposerScope(state: AgentState, scopeKey: string): AgentState { ...state, composerDraftsByScope, composerPendingAttachmentsByScope, + composerContextReferencesByScope, draftTargetsByScope, draftTargetRevisionByScope, messageSendInFlightByScope @@ -3402,19 +3439,23 @@ function normalizeThreadMessage(message: Record, index: number) : undefined; const fileEdits = Array.isArray(message.fileEdits) ? normalizeFileEdits(message.fileEdits) : undefined; const modelError = normalizeModelError(message.modelError ?? message.model_error); - const content = kind === "context_compaction" + const rawContent = kind === "context_compaction" ? String(message.content ?? "") || contextCompactionFallbackText(compactionStatus) : String(message.content ?? ""); + const parsedContent = role === "user" && kind !== "context_compaction" + ? parsePathReferencesFromComposerContent(rawContent) + : { content: rawContent, references: [] }; const normalized = { id: String(message.id ?? `${role}-${index}`), role, - content, + content: parsedContent.content, ...(turnId ? { turnId } : {}), ...(kind ? { kind } : {}), ...(typeof message.reasoning === "string" ? { reasoning: message.reasoning } : {}), ...(typeof message.reasoningStreaming === "boolean" ? { reasoningStreaming: message.reasoningStreaming } : {}), ...(typeof message.isStreaming === "boolean" ? { isStreaming: message.isStreaming } : {}), ...(Array.isArray(message.media) ? { media: normalizeMedia(message.media) } : {}), + ...(parsedContent.references.length ? { contextReferences: parsedContent.references } : {}), ...(kind !== "context_compaction" && Array.isArray(message.traces) ? { traces: message.traces.map(String) } : {}), ...(kind !== "context_compaction" && rawToolEvents ? { toolEvents: normalizeToolProgressEvents(rawToolEvents) } : {}), ...(kind !== "context_compaction" && fileEdits?.length ? { fileEdits } : {}), diff --git a/App/frontend/desktop/src/state/agent-composer-state.ts b/App/frontend/desktop/src/state/agent-composer-state.ts index 83a65bbee..62a174115 100644 --- a/App/frontend/desktop/src/state/agent-composer-state.ts +++ b/App/frontend/desktop/src/state/agent-composer-state.ts @@ -11,6 +11,14 @@ import type { UploadedAgentMedia } from "../api/memmy-agent-client.js"; export type ComposerDraftValue = string | ((currentValue: string) => string); +export interface ComposerContextReference { + kind: "path"; + id: string; + label: string; + fileCount?: number; + totalBytes?: number; +} + export interface PendingAttachmentBase { id: string; sourceKey: string; @@ -33,6 +41,7 @@ export interface PendingImage extends PendingAttachmentBase { export interface PendingFileAttachment extends PendingAttachmentBase { kind: "file"; status: "ready" | "error"; + localPath?: string; uploadBlob?: Blob; uploadMime?: UploadedAgentMedia["mime"]; uploadBytes?: number; diff --git a/App/frontend/desktop/src/state/app-actions.ts b/App/frontend/desktop/src/state/app-actions.ts index 603b7ef5b..3f3caec09 100644 --- a/App/frontend/desktop/src/state/app-actions.ts +++ b/App/frontend/desktop/src/state/app-actions.ts @@ -18,7 +18,7 @@ import type { IntegrationsClient } from "../api/integrations-client.js"; import type { IntegrationConnection } from "../integrations/connection-state.js"; import type { IntegrationMeta } from "../integrations/integration-meta.js"; import type { MemmyAgentRunStatusSnapshot, MemmyAgentSessionSnapshot, MemmyAgentSessionSummary, MemmyAgentSidebarState, MemmyAgentWebuiThread, MemmyAgentWsEvent, WebuiSessionTarget } from "../api/memmy-agent-client.js"; -import type { PendingAttachment } from "./agent-composer-state.js"; +import type { ComposerContextReference, PendingAttachment } from "./agent-composer-state.js"; import type { AgentAction, AgentChatMediaAttachment, @@ -369,7 +369,7 @@ export const agentActions = { return { type: "agent/transientSendFailed", chatId }; }, - userMessageQueued(input: { chatId: string; content: string; media?: AgentChatMediaAttachment[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget }): AppAction { + userMessageQueued(input: { chatId: string; content: string; media?: AgentChatMediaAttachment[]; contextReferences?: ComposerContextReference[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget }): AppAction { return { type: "agent/userMessageQueued", ...input }; }, @@ -381,6 +381,10 @@ export const agentActions = { return { type: "agent/composerPendingAttachmentsUpdated", scopeKey, attachments }; }, + composerContextReferencesUpdated(scopeKey: string, references: ComposerContextReference[]): AppAction { + return { type: "agent/composerContextReferencesUpdated", scopeKey, references }; + }, + draftTargetUpdated(scopeKey: string, target: WebuiSessionTarget): AppAction { return { type: "agent/draftTargetUpdated", scopeKey, target }; }, diff --git a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts index a156a7a0f..dc20b90be 100644 --- a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts +++ b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts @@ -2946,19 +2946,23 @@ describe("agent chat slice", () => { expect(state.tasks.find((task) => task.chatId === "chat-1")?.runStartedAt).toBe(1780732800); }); - it("keeps composer drafts and pending attachments isolated by scope", () => { + it("keeps composer drafts, pending attachments, and context references isolated by scope", () => { const attachment = readyPendingFile("report.pdf"); + const reference = { kind: "path" as const, id: "docs/report.pdf", label: "report.pdf" }; let state = agentReducer(initialAgentState, { type: "agent/composerDraftUpdated", scopeKey: "chat-a", value: "A 草稿" }); state = agentReducer(state, { type: "agent/composerDraftUpdated", scopeKey: "chat-b", value: "B 草稿" }); state = agentReducer(state, { type: "agent/composerPendingAttachmentsUpdated", scopeKey: "chat-a", attachments: [attachment] }); + state = agentReducer(state, { type: "agent/composerContextReferencesUpdated", scopeKey: "chat-a", references: [reference] }); expect(state.composerDraftsByScope).toEqual({ "chat-a": "A 草稿", "chat-b": "B 草稿" }); expect(state.composerPendingAttachmentsByScope["chat-a"]).toEqual([attachment]); + expect(state.composerContextReferencesByScope["chat-a"]).toEqual([reference]); state = agentReducer(state, { type: "agent/composerScopeCleared", scopeKey: "chat-a" }); expect(state.composerDraftsByScope).toEqual({ "chat-b": "B 草稿" }); expect(state.composerPendingAttachmentsByScope["chat-a"]).toBeUndefined(); + expect(state.composerContextReferencesByScope["chat-a"]).toBeUndefined(); }); it("newChatRequested does not clear composer scopes", () => { diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 32e6ab721..fe5187163 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -194,7 +194,81 @@ body.memmy-window-fullscreen { } .home-empty-screen { + display: grid; + grid-template-rows: 1fr auto 1fr; + justify-items: center; + height: 100%; + min-height: 0; padding-bottom: 8%; + overflow-x: hidden; + overflow-y: auto; +} + +/* Brand + composer: true vertical center (same band as before capabilities). */ +.home-empty-mid { + grid-row: 2; + display: flex; + width: min(100%, 42rem); + flex-direction: column; + align-items: center; +} + +.home-empty-mid__composer { + width: 100%; +} + +.home-prompt-suggestions { + display: grid; + gap: 7px; + margin: 18px 2px 0; +} + +.home-prompt-suggestions button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + border: 0; + border-radius: 9px; + padding: 8px 11px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.home-prompt-suggestions button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 68%, transparent); + color: var(--color-text-ink); +} + +.home-prompt-suggestions__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + width: 24px; + height: 24px; + border-radius: 7px; + background: color-mix(in srgb, var(--color-accent-neon-mint) 16%, var(--color-background-paper)); + color: var(--color-action-sky); +} + +.home-prompt-suggestions button > span:nth-child(2) { + min-width: 0; + flex: 1; +} + +/* Capability cards + notice: top of the lower half, snug under the composer. */ +.home-empty-below { + grid-row: 3; + align-self: start; + width: min(100%, 42rem); + padding: 12px 0 28px; } .home-empty-composer, @@ -206,6 +280,134 @@ body.memmy-window-fullscreen { overflow: visible; } +/* Keep @ / popovers above the project toolbar while menus are open. */ +.home-empty-composer--menu-open { + z-index: 60; +} + +.composer-quick-actions { + position: absolute; + bottom: 0.75rem; + left: 1rem; + z-index: 50; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.composer-quick-actions__anchor { + position: relative; +} + +.composer-quick-actions__btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.composer-quick-actions__btn:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +} + +.composer-quick-actions__btn--active { + background: color-mix(in srgb, var(--color-action-sky) 12%, transparent); + color: var(--color-action-sky-hover); +} + +.composer-quick-actions__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 70; +} + +.composer-quick-actions__popover--slash { + width: min(26rem, calc(100vw - 2rem)); +} + +.composer-quick-actions__popover--attach { + display: grid; + width: 132px; + gap: 2px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: var(--shadow-popover); +} + +.composer-quick-actions__popover--attach button { + min-height: 30px; + padding: 0 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.composer-quick-actions__popover--attach button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +} + +.agent-composer-attach-menu { + position: relative; +} + +.agent-composer-attach-menu > summary { + display: flex; + list-style: none; +} + +.agent-composer-attach-menu > summary::-webkit-details-marker { + display: none; +} + +.agent-composer-attach-menu__popover { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + display: grid; + width: 132px; + gap: 2px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: var(--shadow-popover); +} + +.agent-composer-attach-menu__popover button { + min-height: 30px; + padding: 0 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.agent-composer-attach-menu__popover button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +} + +.composer-caret-menu { + position: absolute; + z-index: 70; +} + .home-empty-composer textarea, .agent-composer-shell textarea { background-color: var(--color-background-paper); @@ -218,6 +420,50 @@ body.memmy-window-fullscreen { max-height: 352px; } +.composer-rich-input { + position: relative; +} + +.composer-rich-input--highlighted { + width: 100%; + border-radius: var(--radius-composer); + background-color: var(--color-background-paper); +} + +.composer-rich-input__backdrop { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + padding: 1rem 1.25rem 3rem; + border-radius: inherit; + color: transparent; + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: break-word; + pointer-events: none; +} + +.composer-rich-input__backdrop .composer-slash-chip { + border-radius: 4px; + padding: 1px 0; + background: color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)); + color: var(--color-action-sky-hover); + line-height: 1.6; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + box-shadow: + 4px 0 0 color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)), + -4px 0 0 color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)); +} + +.composer-rich-input__field--highlighted { + position: relative; + z-index: 1; + background: transparent !important; +} + .agent-conversation-composer textarea { max-height: 400px; } @@ -929,6 +1175,30 @@ body:has(.memory-drawer) .window-drag-region { padding-right: var(--codex-content-padding-x); } +.app-page-hero { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); +} + +.app-page-hero h1 { + margin: 0; + color: var(--color-text-ink); + font-size: 18px; + font-weight: 700; + line-height: 28px; +} + +.app-page-hero p { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + font-size: 12px; + line-height: 16px; +} + .app-frame-content-topbar { position: absolute; top: 0; @@ -2269,6 +2539,24 @@ button { padding: 12px 16px 4px; } +.composer-context-attachments { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + overflow-y: auto; + padding: 12px 16px 4px; +} + +.composer-context-attachments > .composer-media-preview-strip, +.composer-context-attachments > .home-context-chips { + display: contents; +} + +.composer-context-attachments .agent-attachment-card { + width: calc((100% - 20px) / 3); +} + /* Keep this more specific than `.agent-composer-shell textarea` above so its generic line-height cannot shift the caret. Keep overflow-y:auto (not hidden): if single-line detection lags behind wrapped content, the field must stay scrollable. */ .agent-composer-shell textarea.agent-composer-input--single { @@ -2280,45 +2568,215 @@ button { line-height: 24px; } -.agent-attachment-card { - display: inline-flex; - width: 220px; - max-width: 100%; - height: 56px; +.file-type-icon { + --file-icon-glyph: var(--file-icon-glyph-generic); + display: inline-grid; + flex: 0 0 auto; + place-items: center; + box-sizing: border-box; min-width: 0; - align-items: center; - gap: 10px; - overflow: hidden; - padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); - border-radius: var(--radius-card); - background: color-mix(in srgb, var(--color-background-paper) 92%, var(--color-canvas-oat)); - box-shadow: 0 8px 20px rgba(17, 29, 28, 0.06); - color: var(--color-text-ink); - text-align: left; - transition: - border-color 160ms ease, - background 160ms ease, - box-shadow 160ms ease, - transform 160ms ease, - opacity 160ms ease; + overflow: visible; + background: transparent; + color: inherit; + line-height: 1; + vertical-align: middle; } -.agent-attachment-card--interactive { - cursor: pointer; +.file-type-icon > svg { + display: block; + flex: none; + width: auto; + height: 100%; + max-width: 100%; + max-height: 100%; + overflow: visible; + shape-rendering: geometricPrecision; } -.agent-attachment-card--interactive:hover { - background: color-mix(in srgb, var(--color-background-paper) 78%, var(--color-canvas-oat)); - box-shadow: 0 10px 22px rgba(17, 29, 28, 0.08); - transform: translateY(-1px); +.file-type-icon__native-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.file-type-icon--inline { + width: var(--file-icon-inline-size); + min-width: var(--file-icon-inline-size); + max-width: var(--file-icon-inline-size); + height: var(--file-icon-inline-size); + min-height: var(--file-icon-inline-size); + max-height: var(--file-icon-inline-size); + flex-basis: var(--file-icon-inline-size); + border-radius: var(--file-icon-inline-radius); + background: transparent; } -.agent-attachment-card--interactive:focus-visible { - outline: none; - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 24%, transparent), - 0 10px 22px rgba(17, 29, 28, 0.08); +.file-type-icon--inline > svg { + height: var(--file-icon-inline-size); +} + +.file-type-icon--row { + width: var(--file-icon-row-size); + min-width: var(--file-icon-row-size); + max-width: var(--file-icon-row-size); + height: var(--file-icon-row-size); + min-height: var(--file-icon-row-size); + max-height: var(--file-icon-row-size); + flex-basis: var(--file-icon-row-size); + border-radius: var(--file-icon-row-radius); +} + +.file-type-icon--row > svg { + height: var(--file-icon-row-size); +} + +.file-type-icon--card { + width: var(--file-icon-card-size); + min-width: var(--file-icon-card-size); + max-width: var(--file-icon-card-size); + height: var(--file-icon-card-size); + min-height: var(--file-icon-card-size); + max-height: var(--file-icon-card-size); + flex-basis: var(--file-icon-card-size); + border-radius: var(--file-icon-card-radius); +} + +.file-type-icon--card > svg { + height: var(--file-icon-card-size); +} + +.file-type-icon__paper { + fill: var(--file-icon-paper); +} + +.file-type-icon__fold { + fill: var(--file-icon-paper-fold); +} + +.file-type-icon__fold-edge { + fill: none; + stroke: var(--file-icon-paper-fold-edge); + stroke-width: 0.5; +} + +.file-type-icon__glyph { + color: var(--file-icon-glyph); +} + +.file-type-icon--card .file-type-icon__glyph { + transform: translateY(-2px) scale(0.88); + transform-box: fill-box; + transform-origin: center; +} + +.file-type-icon__format-label { + fill: currentColor; + color: var(--file-icon-glyph); + font-family: var(--font-sans); + font-size: 5.2px; + font-weight: 700; + letter-spacing: 0.35px; +} + +.file-type-icon--pdf { --file-icon-glyph: var(--file-icon-glyph-pdf); } +.file-type-icon--word { --file-icon-glyph: var(--file-icon-glyph-word); } +.file-type-icon--spreadsheet { --file-icon-glyph: var(--file-icon-glyph-spreadsheet); } +.file-type-icon--presentation { --file-icon-glyph: var(--file-icon-glyph-presentation); } +.file-type-icon--markdown { --file-icon-glyph: var(--file-icon-glyph-markdown); } +.file-type-icon--text { --file-icon-glyph: var(--file-icon-glyph-text); } +.file-type-icon--code { --file-icon-glyph: var(--file-icon-glyph-code); } +.file-type-icon--image { --file-icon-glyph: var(--file-icon-glyph-image); } +.file-type-icon--video { --file-icon-glyph: var(--file-icon-glyph-video); } +.file-type-icon--audio { --file-icon-glyph: var(--file-icon-glyph-audio); } +.file-type-icon--archive { --file-icon-glyph: var(--file-icon-glyph-archive); } + +.file-type-icon--folder { + background: transparent; +} + +.file-type-icon--folder.file-type-icon--inline { + background: transparent; +} + +.file-type-icon--folder > svg { + width: 100%; + height: auto; +} + +.file-type-icon__folder-shadow { + fill: var(--file-icon-shadow); + transform: translate(0.6px, 0.6px); +} + +.file-type-icon__folder-back { + fill: var(--file-icon-folder-back); + stroke: var(--file-icon-folder-edge); + stroke-linejoin: round; + stroke-width: 0.7; +} + +.file-type-icon__folder-paper { + fill: var(--file-icon-folder-paper); + stroke: var(--file-icon-paper-edge); + stroke-width: 0.8; +} + +.file-type-icon__folder-front { + fill: var(--file-icon-folder-front); + stroke: var(--file-icon-folder-edge); + stroke-linejoin: round; + stroke-width: 0.7; +} + +.file-type-icon__folder-highlight { + fill: none; + stroke: var(--file-icon-folder-highlight); + stroke-linecap: round; + stroke-width: 1.1; +} + +.agent-attachment-card { + display: inline-flex; + width: 220px; + max-width: 100%; + height: 56px; + min-width: 0; + align-items: center; + gap: 10px; + overflow: hidden; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-background-paper) 92%, var(--color-canvas-oat)); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.06); + color: var(--color-text-ink); + text-align: left; + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease, + opacity 160ms ease; +} + +.agent-attachment-card--interactive { + cursor: pointer; +} + +.agent-attachment-card--interactive:hover { + background: color-mix(in srgb, var(--color-background-paper) 78%, var(--color-canvas-oat)); + box-shadow: 0 10px 22px rgba(17, 29, 28, 0.08); + transform: translateY(-1px); +} + +.agent-attachment-card--interactive:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 24%, transparent), + 0 10px 22px rgba(17, 29, 28, 0.08); } .agent-attachment-card__action { @@ -2363,8 +2821,7 @@ button { cursor: wait; } -.agent-attachment-card__preview, -.agent-attachment-card__file-tile { +.agent-attachment-card__preview { display: inline-flex; width: 40px; height: 40px; @@ -2397,60 +2854,6 @@ button { color: white; } -.agent-attachment-card__file-tile { - flex-direction: column; - gap: 2px; -} - -.agent-attachment-card__file-tile--sm { - width: 32px; - height: 32px; - flex-basis: 32px; - border-radius: 10px; -} - -.agent-attachment-card__file-tile--md { - width: 40px; - height: 40px; - flex-basis: 40px; -} - -.agent-attachment-card__file-tile--pdf { - background: #fff1f3; - color: #e54861; -} - -.agent-attachment-card__file-tile--docx { - background: #edf6ff; - color: #2179c7; -} - -.agent-attachment-card__file-tile--xlsx { - background: #eefaf2; - color: #238b58; -} - -.agent-attachment-card__file-tile--pptx { - background: #fff6e8; - color: #c96a16; -} - -.agent-attachment-card__file-tile--file { - background: #f4f8f7; - color: #5a6865; -} - -.agent-attachment-card__file-label { - display: block; - max-width: 100%; - overflow: hidden; - font-size: 7px; - font-weight: 800; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - .agent-attachment-card__body { min-width: 0; flex: 1 1 auto; @@ -5537,15 +5940,29 @@ input[type="checkbox"].check-sky:checked::after { } .button-md { + gap: 8px; padding: 9px 14px; } .button-sm { + gap: 6px; min-height: 30px; padding: 7px 10px; font-size: 12px; } +.button-md > svg { + width: 16px; + height: 16px; + flex: none; +} + +.button-sm > svg { + width: 14px; + height: 14px; + flex: none; +} + .button-primary { background: var(--color-action-sky); color: #fff; @@ -9335,3 +9752,2079 @@ input[type="checkbox"].check-sky:checked::after { font-size: 28px; } } + +/* References and uploads share the same attachment-card visual language. */ +.home-context-chips { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + min-width: 0; + padding: 12px 16px 4px; +} + +.home-context-card__icon { + display: inline-grid; + place-items: center; + flex: 0 0 38px; + width: 38px; + height: 38px; + border-radius: 9px; +} + +.home-context-chips .agent-attachment-card { + display: inline-flex; + align-items: center; +} + +.home-capability-strip { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 0; +} + +.home-capability-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 104px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.home-capability-card:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + box-shadow: 0 6px 16px rgba(17, 29, 28, 0.06); +} + +.home-capability-card--active { + border-color: var(--color-action-sky); + background: color-mix(in srgb, var(--color-accent-neon-mint) 10%, var(--color-background-paper)); +} + +.home-capability-card strong { + font-size: 12px; + font-weight: 600; + color: var(--color-text-ink); +} + +.home-capability-card small { + font-size: 10px; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + font-family: var(--font-mono, ui-monospace, monospace); +} + +.home-capability-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-bottom: 4px; + border-radius: var(--radius-btn); +} + +.home-capability-card__icon--literature { + background: color-mix(in srgb, var(--color-accent-neon-mint) 25%, transparent); + color: var(--color-action-sky-hover); +} + +.home-capability-card__icon--slides { + background: rgba(245, 158, 11, 0.14); + color: #b45309; +} + +.home-capability-card__icon--html { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.home-capability-card__icon--sheet { + background: rgba(16, 185, 129, 0.12); + color: #047857; +} + +.home-capability-card__icon--doc { + background: rgba(139, 92, 246, 0.12); + color: #6d28d9; +} + +.home-capability-card__icon--more { + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.home-capability-card__code { + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono, ui-monospace, monospace); +} + +/* ============================================================ + Literature review workflow (/literature-review) + ============================================================ */ + +@keyframes litrev-spin { + to { + transform: rotate(360deg); + } +} + +.litrev-spin { + animation: litrev-spin 1.1s linear infinite; +} + +.litrev-split { + position: relative; + display: flex; + height: 100%; + overflow: hidden; +} + +.litrev-workspace-toggle { + position: absolute; + top: calc((var(--codex-toolbar-height) - var(--codex-toolbar-button-size)) / 2); + right: 8px; + z-index: 60; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: var(--codex-toolbar-button-size); + height: var(--codex-toolbar-button-size); + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; + -webkit-app-region: no-drag; +} + +.litrev-workspace-toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); + color: var(--color-text-ink); +} + +.litrev-workspace-toggle--active { + background: transparent; + color: var(--color-action-sky-hover); +} + +.litrev-workspace-toggle--active:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 20px 4px 12px; +} + +.litrev-conversation { + display: flex; + flex-direction: column; + gap: 14px; +} + +.litrev-user-message { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + width: min(75%, 520px); + margin-left: auto; + align-self: flex-end; +} + +.litrev-user-bubble { + display: inline-block; + width: fit-content; + max-width: 100%; + padding: 10px 13px; + border-radius: 12px 12px 3px 12px; + line-height: 1.6; + white-space: pre-wrap; +} + +.litrev-user-command { + display: inline-flex; + margin: 0 3px; + padding: 1px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--color-action-sky) 14%, transparent); + color: var(--color-action-sky-hover); + font-size: 12px; + font-weight: 600; + line-height: 1.5; + vertical-align: baseline; +} + +.litrev-user-message__contexts { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.litrev-user-message__contexts .agent-attachment-card { + width: 210px; + height: 50px; +} + +.litrev-assistant-copy { + font-size: 14px; + line-height: 1.7; + color: var(--color-text-ink); +} + +.litrev-assistant-copy--muted { + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.litrev-qa-summary { + display: grid; + width: 100%; + gap: 10px; + margin-top: 4px; + padding: 12px 14px; + border: 0; + border-radius: 14px; + background: color-mix(in srgb, var(--color-canvas-oat) 55%, var(--color-background-paper)); +} + +.litrev-qa-summary > div { + display: block; +} + +.litrev-qa-summary small { + display: block; + font-size: 12px; + line-height: 1.4; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.litrev-qa-summary strong { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + color: var(--color-text-ink); +} + +.litrev-supplement { + display: flex; + flex-direction: column; + gap: 10px; +} + +.litrev-supplement > .litrev-user-bubble { + margin-left: auto; + align-self: flex-end; +} + +.litrev-supplement > .litrev-assistant-copy { + align-self: flex-start; +} + +.composer-file-context-menu { + position: fixed; + z-index: 120; + min-width: 170px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: 0 8px 24px rgba(17, 29, 28, 0.12); +} + +.composer-file-context-menu button { + display: flex; + align-items: center; + width: 100%; + min-height: 32px; + border: 0; + border-radius: 6px; + padding: 0 9px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.composer-file-context-menu button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +} + +.composer-file-context-menu__submenu-trigger svg { + margin-left: auto; +} + +.composer-file-context-menu__submenu-anchor { + position: relative; +} + +.composer-file-context-menu__submenu { + position: absolute; + z-index: 1; + top: -5px; + left: calc(100% + 6px); + display: grid; + gap: 1px; + width: 180px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: 0 8px 24px rgba(17, 29, 28, 0.12); +} + +.composer-file-context-menu--submenu-left .composer-file-context-menu__submenu { + right: calc(100% + 6px); + left: auto; +} + +.composer-file-context-menu__submenu button { + min-height: 30px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); +} + +.composer-file-context-menu .composer-file-context-menu__danger { + color: var(--color-danger); +} + +/* Prototype `.tool-history` — keep this chrome fixed; do not card-ify. */ +/* Literature-review mock reuses session agent-activity chrome; keep spacing only. */ +.litrev-agent-activity { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; + width: 100%; +} + +.litrev-conversation .agent-activity-timeline-item, +.litrev-conversation .agent-activity-cluster { + margin-top: 8px; +} + +.litrev-activity-history-item { + margin-top: 6px; + padding: 2px 0; + color: color-mix(in srgb, var(--color-text-ink) 56%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.55; +} + +.litrev-status-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + max-width: 100%; + min-height: 30px; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.litrev-status-toggle > svg:last-child { + flex: none; + opacity: 0.55; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + transition: transform 150ms ease; +} + +.litrev-status-toggle--static { + cursor: default; +} + +.agent-activity-cluster--open > * > .litrev-status-toggle > svg:last-child, +.agent-activity-cluster--open > .litrev-status-toggle > svg:last-child { + transform: rotate(180deg); +} + +.litrev-completed-activity, +.litrev-cancelled-activity { + width: 100%; +} + +.litrev-completed-activity .agent-activity-cluster__body, +.litrev-cancelled-activity .agent-activity-cluster__body { + width: 100%; +} + +.litrev-stage-activity { + width: 100%; +} + +.litrev-preparation-summary { + width: 100%; +} + +.litrev-task-process { + width: 100%; +} + +.litrev-task-process__body { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 6px; +} + +.litrev-task-process__body > .litrev-assistant-copy:first-child { + margin-top: 0; +} + +.litrev-preparation-summary__body { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + margin-top: 6px; +} + +.litrev-preparation-summary__body > .litrev-stage-output-message { + margin: 0 0 8px; + padding-left: 2px; +} + +.litrev-preparation-stage > button { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + max-width: 100%; + min-height: 30px; + padding: 0 2px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.litrev-preparation-stage > button span { + min-width: 0; +} + +.litrev-preparation-stage > button svg { + flex: none; + opacity: 0.45; + transition: transform 150ms ease; +} + +.litrev-preparation-stage--open > button svg { + transform: rotate(180deg); +} + +.litrev-preparation-stage > .litrev-stage-text-card, +.litrev-preparation-stage > .litrev-qa-summary { + margin: 2px 0 8px; +} + +.litrev-preparation-stage > .litrev-stage-text-card { + width: 100%; +} + +.litrev-stage-activity__body { + width: 100%; + margin-top: 8px; +} + +.litrev-stage-activity__body > .litrev-question-card, +.litrev-stage-activity__body > .litrev-wizard-card { + width: 100%; +} + +.litrev-stage-thinking-copy { + margin: 0; + padding: 2px 0 4px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 12px; + line-height: 1.5; +} + +.litrev-stage-text-card { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + padding: 9px 14px; + border-radius: 14px; + background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); +} + +.litrev-cancelled-stage-notice { + width: min(620px, 100%); + margin: 4px 0; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + font-size: 11px; + line-height: 1.45; +} + +.litrev-stage-text-card > div { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 32px; + padding: 7px 2px; +} + +.litrev-stage-text-card > div + div { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); +} + +.litrev-stage-text-card strong { + min-width: 0; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + font-weight: 500; + line-height: 1.4; +} + +.litrev-stage-text-card small { + margin-left: auto; + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); + font-size: 11px; + white-space: nowrap; +} + +.litrev-stage-text-card--outline > div[data-level="1"] { + padding-left: 22px; +} + +.litrev-cancelled-activity__body { + margin: 4px 0 0; + padding: 4px 0 2px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + font-size: 12px; +} + +.litrev-dock { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 4px 32px; +} + +.litrev-dock:empty { + display: none; +} + +.litrev-question-status { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + min-height: 30px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 13px; +} + +.litrev-question-status--cancelled { + opacity: 0.72; +} + +.litrev-question-card { + width: 100%; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-card-lg, 16px); + background: var(--color-background-paper); + box-shadow: 0 12px 32px rgba(17, 29, 28, 0.08); + overflow: hidden; +} + +.litrev-question-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + padding: 10px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-question-card__meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex: none; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-question-card__meta button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; +} + +.litrev-question-card__meta button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.litrev-question-card__head h2 { + min-width: 0; + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-question-list { + max-height: min(54vh, 440px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.litrev-question-item { + padding: 12px 0 4px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); +} + +.litrev-question-item:last-child { + border-bottom: 0; +} + +.litrev-question-item__title { + display: block; + padding: 0 14px 8px; +} + +.litrev-question-item__title h3 { + margin: 0; + color: var(--color-text-ink); + font-size: 13px; + font-weight: 600; + line-height: 1.45; +} + +.litrev-question-options { + display: flex; + flex-direction: column; + gap: 0; + padding: 0 12px; +} + +.litrev-question-option { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 8px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); + border-radius: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.litrev-question-option:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 56%, transparent); +} + +.litrev-question-option--selected { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); + box-shadow: inset 2px 0 0 var(--color-action-sky); +} + +.litrev-question-option__number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.litrev-question-option__label { + min-width: 0; + font-size: 13px; + color: var(--color-text-ink); +} + +.litrev-question-option__state { + display: inline-flex; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.litrev-question-option--selected .litrev-question-option__state { + color: var(--color-action-sky-hover); +} + +.litrev-question-supplement { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 9px; + min-height: 42px; + margin: 0 12px 10px; + padding: 7px 8px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.litrev-question-supplement input { + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--color-text-ink); + font-size: 13px; +} + +.litrev-question-supplement input::placeholder { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.litrev-question-supplement button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 7px; + color: var(--color-action-sky-hover); + cursor: pointer; +} + +.litrev-question-supplement button:disabled { + opacity: 0; + pointer-events: none; +} + +.litrev-question-card__foot { + display: flex; + justify-content: flex-end; + padding: 10px 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: var(--color-background-paper); +} + +.litrev-wizard-card { + width: 100%; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-card-lg, 16px); + background: var(--color-background-paper); + box-shadow: 0 12px 32px rgba(17, 29, 28, 0.08); + overflow: hidden; +} + +.litrev-wizard-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 13px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-wizard-card__head strong { + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-wizard-card__count { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-wizard-card__body { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 300px; + padding: 14px 16px; + overflow-y: auto; +} + +.litrev-wizard-card__foot { + display: flex; + min-height: 58px; + align-items: center; + gap: 7px; + padding: 8px 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-wizard-card__foot i { + flex: 1; +} + +/* Prototype `.btn`: ~31px tall, light weight — not the global md primary. */ +.litrev-wizard-card__foot .button, +.litrev-question-card__foot .button, +.litrev-keyword-add-form .button { + min-height: 31px; + padding: 0 11px; + font-size: 12px; + font-weight: 600; + box-shadow: none; +} + +.litrev-wizard-card__foot .button:hover:not(:disabled), +.litrev-question-card__foot .button:hover:not(:disabled), +.litrev-keyword-add-form .button:hover:not(:disabled) { + transform: none; +} + +.litrev-section-hint { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.litrev-source-card__body { + gap: 12px; +} + +.litrev-source-card__policy { + margin: -4px 0 0; + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + font-size: 11px; + line-height: 1.5; +} + +.litrev-source-card__empty { + display: grid; + min-height: 66px; + place-items: center; + padding: 12px; + border: 1px dashed color-mix(in srgb, var(--color-border-stone) 72%, transparent); + border-radius: 9px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-size: 12px; + text-align: center; +} + +.litrev-source-list { + overflow: hidden auto; + max-height: 184px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-background-paper) 88%, transparent); +} + +.litrev-source-list__row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto 28px; + align-items: center; + min-height: 42px; + padding: 5px 8px; + gap: 8px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); +} + +.litrev-source-list__row:last-child { + border-bottom: 0; +} + +.litrev-source-list__name { + overflow: hidden; + color: var(--color-text-ink); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-source-list__row small { + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); + font-size: 10px; + white-space: nowrap; +} + +.litrev-source-list__row > button { + display: inline-grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + cursor: pointer; +} + +.litrev-source-list__row > button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 76%, transparent); + color: var(--color-text-ink); +} + +.litrev-source-card__notice { + margin: -2px 0 0; + padding: 7px 9px; + border-radius: 7px; + background: color-mix(in srgb, #f59e0b 11%, transparent); + color: color-mix(in srgb, #9a5a00 88%, var(--color-text-ink)); + font-size: 11px; + line-height: 1.45; +} + +.litrev-source-card__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.litrev-source-card__actions .button { + min-height: 32px; + box-shadow: none; +} + +.litrev-stage-empty { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-size: 12px; +} + +.litrev-keyword-select-all { + display: flex; + min-height: 26px; + align-items: center; + gap: 8px; + padding: 0 3px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + cursor: pointer; +} + +.litrev-keyword-select-all > input, +.litrev-keyword-row__select > input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.litrev-keyword-select-all strong { + font-size: 12px; + font-weight: 600; +} + +.litrev-keyword-select-all small { + margin-left: auto; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + font-size: 11px; +} + +.litrev-keyword-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.litrev-keyword-row, +.litrev-keyword-add-row { + display: grid; + min-height: 52px; + grid-template-columns: 18px minmax(0, 1fr) 1px minmax(150px, 0.72fr); + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: 8px; + background: var(--color-background-paper); + transition: border-color 140ms ease, opacity 140ms ease; +} + +.litrev-keyword-row:focus-within, +.litrev-keyword-add-row:focus-within { + border-color: color-mix(in srgb, var(--color-action-sky) 72%, transparent); +} + +.litrev-keyword-row--unselected { + opacity: 0.5; +} + +.litrev-keyword-row__select { + display: inline-flex; + cursor: pointer; +} + +.litrev-keyword-row input[type="text"], +.litrev-keyword-add-row__field input { + min-width: 0; + width: 100%; + padding: 5px 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + line-height: 20px; +} + +.litrev-keyword-row input[type="text"]:focus, +.litrev-keyword-add-row__field input:focus { + background: color-mix(in srgb, var(--color-canvas-oat) 58%, transparent); + outline: none; +} + +.litrev-keyword-row__divider { + width: 1px; + height: 28px; + background: color-mix(in srgb, var(--color-border-stone) 45%, transparent); +} + +.litrev-keyword-row__weight { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(72px, 1fr) 16px; + align-items: center; + gap: 8px; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + font-size: 10px; +} + +.litrev-keyword-row__weight input[type="range"] { + height: 14px; + min-width: 0; + width: 100%; + appearance: none; + background: transparent; + cursor: pointer; +} + +.litrev-keyword-row__weight input[type="range"]::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient( + to right, + var(--color-action-sky) 0, + var(--color-action-sky) var(--litrev-weight-progress), + color-mix(in srgb, var(--color-border-stone) 52%, transparent) var(--litrev-weight-progress), + color-mix(in srgb, var(--color-border-stone) 52%, transparent) 100% + ); +} + +.litrev-keyword-row__weight input[type="range"]::-webkit-slider-thumb { + width: 12px; + height: 12px; + margin-top: -4px; + appearance: none; + border: 2px solid var(--color-action-sky); + border-radius: 999px; + background: var(--color-background-paper); + box-shadow: 0 1px 3px rgba(17, 29, 28, 0.12); +} + +.litrev-keyword-row__weight input[type="range"]::-moz-range-track { + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-border-stone) 52%, transparent); +} + +.litrev-keyword-row__weight input[type="range"]::-moz-range-progress { + height: 4px; + border-radius: 999px; + background: var(--color-action-sky); +} + +.litrev-keyword-row__weight input[type="range"]::-moz-range-thumb { + width: 9px; + height: 9px; + border: 2px solid var(--color-action-sky); + border-radius: 999px; + background: var(--color-background-paper); + box-shadow: 0 1px 3px rgba(17, 29, 28, 0.12); +} + +.litrev-keyword-row__weight input[type="range"]:focus-visible { + outline: none; +} + +.litrev-keyword-row__weight input[type="range"]:focus-visible::-webkit-slider-thumb { + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--color-action-sky) 18%, transparent), + 0 1px 3px rgba(17, 29, 28, 0.12); +} + +.litrev-keyword-row__weight output { + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + font-size: 11px; + text-align: right; +} + +.litrev-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + cursor: pointer; +} + +.litrev-icon-button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 80%, transparent); + color: var(--color-text-ink); +} + +.litrev-keyword-add-form { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 7px; +} + +.litrev-keyword-add-row { + width: 100%; +} + +.litrev-keyword-add-row__field { + display: flex; + min-width: 0; + align-items: center; +} + +.litrev-keyword-add-row__field small { + flex: none; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + font-size: 10px; +} + +.litrev-outline-rows { + display: flex; + flex-direction: column; + gap: 2px; +} + +.litrev-outline-row { + position: relative; + display: grid; + min-height: 36px; + grid-template-columns: 8px 18px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 1px 4px; + border-radius: 7px; + transition: background-color 140ms ease, box-shadow 140ms ease; +} + +.litrev-outline-row:hover, +.litrev-outline-row:focus-within { + background: color-mix(in srgb, var(--color-canvas-oat) 66%, transparent); +} + +.litrev-outline-row--child { + margin-left: 24px; +} + +.litrev-outline-row input { + min-width: 0; + width: 100%; + flex: 1; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + font-weight: 600; + line-height: 20px; +} + +.litrev-outline-row--child input { + font-weight: 400; +} + +.litrev-outline-row input:focus { + border-color: color-mix(in srgb, var(--color-action-sky) 72%, transparent); + background: var(--color-background-paper); + outline: none; +} + +.litrev-outline-row__marker { + display: block; + width: 5px; + height: 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-action-sky) 48%, transparent); +} + +.litrev-outline-row--child .litrev-outline-row__marker { + border: 1px solid color-mix(in srgb, var(--color-action-sky) 42%, transparent); + background: transparent; +} + +.litrev-outline-row__grip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 30%, transparent); + cursor: grab; + user-select: none; +} + +.litrev-outline-row__grip:active { + cursor: grabbing; +} + +.litrev-outline-row__actions { + display: inline-flex; + align-items: center; + opacity: 0; + transition: opacity 140ms ease; +} + +.litrev-outline-row:hover .litrev-outline-row__actions, +.litrev-outline-row:focus-within .litrev-outline-row__actions { + opacity: 1; +} + +.litrev-outline-row--drop::before { + position: absolute; + top: -4px; + right: 0; + left: 0; + height: 2px; + border-radius: 999px; + background: var(--color-action-sky); + content: ""; + pointer-events: none; +} + +.litrev-outline-row--drop-child::before { + left: 0; +} + +.litrev-wizard-card__head-actions { + display: inline-flex; + align-items: center; + gap: 12px; + flex: none; +} + +.litrev-wizard-card__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; +} + +.litrev-wizard-card__close:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.litrev-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 90%, transparent); + border-radius: 4px; + background: var(--color-background-paper); + color: #fff; + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease; +} + +.litrev-checkbox--checked { + border-color: var(--color-action-sky); + background: var(--color-action-sky); +} + +.litrev-ref-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.litrev-ref-summary p { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + font-size: 11px; + line-height: 1.55; +} + +.litrev-ref-summary strong { + flex: none; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 11px; + font-weight: 500; + line-height: 1.55; +} + +.litrev-ref-table { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 52%, transparent); + border-radius: 8px; +} + +.litrev-ref-table__head, +.litrev-ref-row { + display: grid; + grid-template-columns: 28px 24px minmax(0, 1fr) 104px; + align-items: center; +} + +.litrev-ref-table__head { + min-height: 34px; + background: color-mix(in srgb, var(--color-canvas-oat) 54%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +} + +.litrev-ref-table__head > strong { + padding: 0 10px; + font-size: 10px; + font-weight: 600; +} + +.litrev-ref-table__select-all { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.litrev-ref-table__select-all > input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.litrev-ref-row { + min-height: 54px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); + background: var(--color-background-paper); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.litrev-ref-row:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 50%, var(--color-background-paper)); +} + +.litrev-ref-row--selected { + background: color-mix(in srgb, var(--color-action-sky) 4%, var(--color-background-paper)); +} + +.litrev-ref-row > input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.litrev-ref-row > .litrev-checkbox { + justify-self: center; +} + +.litrev-ref-row__index { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + font-size: 10px; + text-align: center; +} + +.litrev-ref-row__text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + padding: 8px 10px; +} + +.litrev-ref-row__text strong { + overflow: hidden; + color: var(--color-text-ink); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-ref-row__text small { + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + font-size: 10px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-ref-row__source { + flex: none; + padding: 0 10px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + font-size: 10px; + font-weight: 600; +} + +.litrev-ref-row__source--web { + color: var(--color-action-sky); +} + +.litrev-composer { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0; + border: 1px solid var(--border-content-panel, var(--color-border-stone)); + border-radius: var(--radius-composer); + background: var(--color-background-paper); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.03); +} + +.litrev-composer textarea { + display: block; + width: 100%; + min-width: 0; + max-height: 92px; + min-height: 88px; + padding: 12px 68px 44px 20px; + border: 0; + border-radius: var(--radius-composer); + background: transparent; + font-size: 13px; + line-height: 1.6; + color: var(--color-text-ink); + resize: none; + outline: none; +} + +.litrev-composer .composer-quick-actions__popover { + top: auto; + bottom: calc(100% + 8px); +} + +.litrev-composer__actions { + position: absolute; + right: 16px; + bottom: 12px; + z-index: 50; + display: flex; + align-items: center; + gap: 8px; +} + +.litrev-composer__voice { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; +} + +.litrev-composer__voice:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +} + +.litrev-composer__voice--active { + color: var(--color-action-sky); +} + +.litrev-composer__voice:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.litrev-composer__send { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 32px; + height: 32px; + border: 0; + border-radius: var(--radius-btn); + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 35%, transparent); + cursor: pointer; +} + +.litrev-composer__send:disabled { + cursor: not-allowed; +} + +.litrev-composer__send--ready { + background: var(--color-action-sky); + color: #fff; +} + +.litrev-composer__send--ready:hover { + background: var(--color-action-sky-hover); +} + +.litrev-todo { + width: min(620px, 100%); + padding: 12px 16px 14px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 46%, transparent); + border-radius: var(--radius-card, 12px); + background: color-mix(in srgb, var(--color-background-paper) 96%, var(--color-canvas-oat)); +} + +.litrev-todo--finished { + width: fit-content; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.litrev-todo__toggle { + display: inline-flex; + align-items: center; + gap: 7px; + width: auto; + padding: 2px 0; + font-size: 13px; + font-weight: 400; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + cursor: pointer; +} + +.litrev-todo__list { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding: 2px 0; +} + +.litrev-todo__list.litrev-stage-text-card { + gap: 0; + margin-top: 0; + padding: 9px 14px; +} + +.litrev-todo__item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.litrev-task-output-message { + max-width: 620px; + margin-top: 10px; +} + +.litrev-stage-output-message { + max-width: 620px; + margin-top: 8px; +} + +.litrev-todo__item strong { + font-size: 12px; + font-weight: 500; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.litrev-todo__item--done strong { + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +.litrev-todo__item--current strong { + color: var(--color-action-sky-hover); +} + +.litrev-todo__status { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: 999px; + color: var(--color-action-sky-hover); +} + +.litrev-todo__item--done .litrev-todo__status { + border-color: transparent; + background: color-mix(in srgb, var(--color-accent-neon-mint) 30%, transparent); +} + +.litrev-todo__item--current .litrev-todo__status { + border-color: color-mix(in srgb, var(--color-action-sky) 50%, transparent); +} + +.litrev-todo__item small { + margin-left: auto; + font-size: 11px; + color: var(--color-action-sky-hover); +} + +.litrev-file-cards { + display: grid; + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.litrev-file-card { + display: flex; + min-width: 0; + width: 100%; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.litrev-file-card:only-child { + grid-column: 1 / -1; +} + +.litrev-file-card__text { + min-width: 0; +} + +.litrev-file-card:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + box-shadow: 0 6px 16px rgba(17, 29, 28, 0.06); +} + +.litrev-file-card__text strong { + display: block; + overflow: hidden; + font-size: 13px; + font-weight: 500; + color: var(--color-text-ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-file-card__text small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-chat-pane { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + height: 100%; + max-width: 780px; + margin: 0 auto; + padding: 0 20px; +} + +.litrev-chat-pane--with-side { + max-width: none; + margin: 0; +} + +.litrev-chat-pane__topbar { + display: flex; + align-items: center; + gap: 10px; + flex: none; + height: var(--codex-toolbar-height); + min-height: var(--codex-toolbar-height); + -webkit-app-region: drag; + user-select: none; +} + +.litrev-chat-pane__topbar .agent-conversation-title { + position: absolute; + top: 0; + left: 20px; + display: flex; + align-items: center; + height: var(--codex-toolbar-height); + min-width: 0; + max-width: calc(100% - 76px); +} + +.litrev-preview-pane { + display: flex; + flex-direction: column; + flex: none; + width: 44%; + min-width: 380px; + height: 100%; + border-left: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + background: var(--color-background-paper); +} + +.litrev-preview-pane--lifted { + -webkit-app-region: no-drag; +} + +.litrev-preview-toolbar { + display: flex; + align-items: center; + gap: 4px; + flex: none; + height: var(--codex-toolbar-height); + min-height: var(--codex-toolbar-height); + padding: 0 44px 0 8px; + overflow: hidden; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: var(--color-background-paper); + -webkit-app-region: no-drag; +} + +.litrev-file-tabs { + display: flex; + align-items: stretch; + gap: 2px; + flex: 1; + min-width: 0; + height: 100%; + overflow-x: auto; +} + +.litrev-file-tab { + display: inline-flex; + align-items: center; + gap: 2px; + flex: none; + max-width: 190px; + height: 100%; + padding: 0 2px 0 8px; + border-bottom: 2px solid transparent; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + -webkit-app-region: no-drag; +} + +.litrev-file-tab > button[role="tab"] { + min-width: 0; + overflow: hidden; + color: inherit; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.litrev-file-tab__close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 18px; + height: 18px; + border-radius: 4px; + color: inherit; + opacity: 0.55; + cursor: pointer; +} + +.litrev-file-tab__close:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + opacity: 1; +} + +.litrev-file-tab--active { + border-bottom-color: var(--color-action-sky); + color: var(--color-action-sky-hover); + font-weight: 600; +} + +.litrev-file-tab:hover:not(.litrev-file-tab--active) { + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 45%, transparent); +} + +.litrev-preview-body { + display: flex; + flex: 1; + min-height: 0; +} + +.litrev-file-browser { + display: flex; + flex: none; + flex-direction: column; + width: 200px; + min-height: 0; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 12%, var(--color-background-paper)); +} + +.litrev-file-browser--collapsed { + display: none; +} + +.litrev-file-browser__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: var(--codex-toolbar-button-size); + height: var(--codex-toolbar-button-size); + padding: 0; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.litrev-file-browser__toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-file-list { + flex: 1; + width: auto; + min-height: 0; + padding: 8px; + overflow-y: auto; +} + +.litrev-preview-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.litrev-preview-empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + padding: 24px; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + text-align: center; +} + +.litrev-preview-empty strong { + color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); + font-size: 13px; + font-weight: 600; +} + +.litrev-preview-empty small { + max-width: 260px; + font-size: 11px; + line-height: 1.5; +} + +.litrev-file-folder__toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-height: 36px; + padding: 7px 6px; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.litrev-file-folder__toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-file-folder__toggle strong { + font-size: 12px; + font-weight: 600; +} + +.litrev-file-folder__children { + display: flex; + flex-direction: column; + margin-left: 14px; +} + +.litrev-file-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 7px 8px; + border-radius: var(--radius-btn); + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); + text-align: left; + cursor: pointer; +} + +.litrev-file-item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-file-item--active { + background: color-mix(in srgb, var(--color-accent-neon-mint) 14%, transparent); + color: var(--color-action-sky-hover); + font-weight: 500; +} + +.litrev-file-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-preview-document { + flex: 1; + min-width: 0; + padding: 16px 22px 28px; + overflow-y: auto; +} + +.litrev-preview-crumb { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-preview-document h2 { + margin: 8px 0 4px; + font-size: 17px; + font-weight: 700; + color: var(--color-text-ink); +} + +.litrev-preview-document h3 { + margin-top: 16px; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-preview-document p { + margin-top: 6px; + font-size: 13px; + line-height: 1.85; + color: color-mix(in srgb, var(--color-text-ink) 80%, transparent); +} + +.litrev-workspace-drawer { + display: flex; + flex-direction: column; + flex: none; + width: 400px; + height: 100%; + border-left: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + background: var(--color-background-paper); +} + +.litrev-workspace-drawer__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: none; + padding: 12px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-workspace-drawer__head strong { + font-size: 13px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-workspace-drawer__body { + display: flex; + flex: 1; + min-height: 0; +} + +.litrev-workspace-drawer__files { + flex: none; + width: 160px; + padding: 8px; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + overflow-y: auto; +} + +.litrev-workspace-drawer__preview { + flex: 1; + min-width: 0; + padding: 14px 16px; + overflow-y: auto; +} + +.litrev-workspace-drawer__preview small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-workspace-drawer__preview h3 { + margin: 6px 0 4px; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-workspace-drawer__preview p { + margin-top: 6px; + font-size: 12px; + line-height: 1.75; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +} + +.litrev-workspace-empty { + display: flex; + flex-direction: column; + gap: 4px; + padding: 30px 10px; + text-align: center; +} + +.litrev-workspace-empty strong { + font-size: 13px; + color: var(--color-text-ink); +} + +.litrev-workspace-empty small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} diff --git a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts index 43021965f..9689403f4 100644 --- a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts +++ b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts @@ -36,19 +36,19 @@ describe("prototype page structure alignment", () => { expect(source("pages/app-frame.tsx")).toContain("LayoutList"); expect(source("pages/app-frame.tsx")).not.toContain('icon: "+"'); expect(source("pages/app-frame.tsx")).not.toContain('icon: "M"'); - expect(source("pages/home-page.tsx")).toContain("app-frame-page-content home-empty-screen flex flex-col items-center justify-center h-full"); + expect(source("pages/home-page.tsx")).toContain('className="app-frame-page-content home-empty-screen"'); expect(source("styles.css")).toContain(".home-empty-screen"); expect(source("styles.css")).toContain("padding-bottom: 8%;"); expect(source("pages/home-page.tsx")).toContain("text-center mb-8"); expect(source("pages/home-page.tsx")).toContain("home-empty-brand-mascot flex justify-center"); expect(source("pages/home-page.tsx")).toContain("text-2xl font-bold text-text-ink"); - expect(source("pages/home-page.tsx")).toContain("w-full max-w-2xl"); + expect(source("pages/home-page.tsx")).toContain('className="home-empty-composer-stack"'); expect(source("styles.css")).toContain(".home-empty-composer"); expect(source("styles.css")).toContain(".agent-composer-shell"); expect(source("pages/home-page.tsx")).toContain("relative home-empty-composer agent-composer-shell rounded-card-lg"); expect(source("pages/home-page.tsx")).toContain("w-full px-5 pt-4 pb-12 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40"); expect(source("pages/home-page.tsx")).toContain("absolute bottom-3 right-4 flex items-center gap-2"); - expect(source("pages/home-page.tsx")).not.toContain("home.suggestion."); + expect(source("pages/home-page.tsx")).toContain('t("home.suggestion.one")'); expect(source("pages/home-page.tsx")).toContain("agent-conversation-panel flex flex-col h-full"); expect(source("pages/home-page.tsx")).toContain("app-frame-page-content agent-conversation-scroll flex-1 overflow-y-auto"); expect(source("pages/home-page.tsx")).toContain("max-w-3xl mx-auto space-y-3"); diff --git a/App/frontend/desktop/src/theme/tokens.css b/App/frontend/desktop/src/theme/tokens.css index 8fb99e0bd..6a210a00d 100644 --- a/App/frontend/desktop/src/theme/tokens.css +++ b/App/frontend/desktop/src/theme/tokens.css @@ -52,6 +52,37 @@ --color-role-assistant: #8ba1f3; --color-role-assistant-soft: #e2e8fc; + /* File resources: light paper with familiar, full-strength format colors. */ + --file-icon-inline-size: 16px; + --file-icon-row-size: 22px; + --file-icon-card-size: 36px; + --file-icon-inline-radius: 0; + --file-icon-row-radius: 0; + --file-icon-card-radius: 0; + --file-icon-paper: #f3f6f5; + --file-icon-paper-sheen: #ffffff; + --file-icon-paper-edge: #c8d0ce; + --file-icon-paper-fold: #e1e8e6; + --file-icon-paper-fold-edge: #b9c4c1; + --file-icon-shadow: rgba(17, 29, 28, 0.16); + --file-icon-glyph-generic: #5f6b73; + --file-icon-glyph-pdf: #e5484d; + --file-icon-glyph-word: #2f6fdb; + --file-icon-glyph-spreadsheet: #1f9d63; + --file-icon-glyph-presentation: #e56f2d; + --file-icon-glyph-markdown: #3f6f9e; + --file-icon-glyph-text: #5f6b73; + --file-icon-glyph-code: #7357b8; + --file-icon-glyph-image: #8a55b5; + --file-icon-glyph-video: #7658b6; + --file-icon-glyph-audio: #d06b32; + --file-icon-glyph-archive: #a56b28; + --file-icon-folder-back: #79c5ef; + --file-icon-folder-front: #49a9e2; + --file-icon-folder-edge: #2e89bd; + --file-icon-folder-highlight: rgba(255, 255, 255, 0.72); + --file-icon-folder-paper: #f7f4ec; + /* Font sans stack. */ --font-sans: "Nunito", "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable", "Segoe UI", "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; /* Font chat stack. */ diff --git a/App/shell/desktop/interface/src/index.ts b/App/shell/desktop/interface/src/index.ts index 39af7ddd6..329b7dc8d 100644 --- a/App/shell/desktop/interface/src/index.ts +++ b/App/shell/desktop/interface/src/index.ts @@ -65,6 +65,10 @@ export interface DesktopImageActionRequest { data?: Uint8Array; } +export type DesktopSystemFileIconResult = string | null; + +export type DesktopSystemFolderIconKind = "folder" | "documents" | "downloads" | "desktop"; + export type DesktopImageSaveResult = | { canceled: true } | { canceled: false; filePath: string; bytes: number }; diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 9434a3056..c0c314f35 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -7,6 +7,8 @@ import type { DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopRuntimeConfig, + DesktopSystemFileIconResult, + DesktopSystemFolderIconKind, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateDownloadOptions, @@ -19,7 +21,7 @@ import { spawn } from "node:child_process"; import { constants as fsConstants, cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { access, appendFile, chmod, copyFile, lstat, mkdir, open, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import YAML from "yaml"; import { fullWindowOptions, @@ -798,6 +800,32 @@ async function restartMemoryService(): Promise { + const [rootName, ...relativeParts] = trimmed.split(/[\\/]+/).filter(Boolean); + const root = rootName === "文稿" || rootName === "Documents" + ? app.getPath("documents") + : rootName === "下载" || rootName === "Downloads" + ? app.getPath("downloads") + : rootName === "桌面" || rootName === "Desktop" + ? app.getPath("desktop") + : join(homedir(), rootName ?? ""); + return join(root, ...relativeParts); + })(); + + let existingTarget = target; + while (!existsSync(existingTarget)) { + const parent = dirname(existingTarget); + if (parent === existingTarget) return null; + existingTarget = parent; + } + return existingTarget; +} + /** * Registers the IPC handlers the renderer uses to read the runtime config. * @returns Nothing. @@ -842,6 +870,44 @@ function registerIpcHandlers(): void { saveDesktopImage(request, event.sender.getURL(), BrowserWindow.fromWebContents(event.sender)) )); + ipcMain.handle("memmy:get-system-file-icon", async (_event, filePath: string): Promise => { + if (typeof filePath !== "string" || !isAbsolute(filePath) || !existsSync(filePath)) { + return null; + } + try { + const icon = await app.getFileIcon(filePath, { size: "large" }); + return icon.isEmpty() ? null : icon.toDataURL(); + } catch { + return null; + } + }); + + ipcMain.handle("memmy:get-system-folder-icon", async (_event, kind: DesktopSystemFolderIconKind): Promise => { + // Generic "folder" used to call getFileIcon(temp) and could SIGTRAP under load; skip it. + const pathName = kind === "documents" + ? "documents" + : kind === "downloads" + ? "downloads" + : kind === "desktop" + ? "desktop" + : null; + if (!pathName) return null; + try { + const folderPath = app.getPath(pathName); + if (!existsSync(folderPath)) return null; + const icon = await app.getFileIcon(folderPath, { size: "normal" }); + return icon.isEmpty() ? null : icon.toDataURL(); + } catch { + return null; + } + }); + + ipcMain.handle("memmy:show-item-in-folder", async (_event, filePath: string): Promise => { + if (typeof filePath !== "string") return; + const target = resolveShowItemInFolderPath(filePath); + if (target) shell.showItemInFolder(target); + }); + ipcMain.handle("memmy:notify-task-done", (_event, payload: { title: string; body: string; silent: boolean }) => { showTaskDoneNotification(payload); }); @@ -4719,6 +4785,9 @@ async function cleanupBeforeQuit(): Promise { ipcMain.removeHandler("memmy:openMailto"); ipcMain.removeHandler("memmy:copy-image-to-clipboard"); ipcMain.removeHandler("memmy:save-image"); + ipcMain.removeHandler("memmy:get-system-file-icon"); + ipcMain.removeHandler("memmy:get-system-folder-icon"); + ipcMain.removeHandler("memmy:show-item-in-folder"); ipcMain.removeHandler("memmy:export-memory-database"); ipcMain.removeHandler("memmy:install-cli-tools"); ipcMain.removeHandler("memmy:restart-memory-service"); diff --git a/App/shell/desktop/src/preload/preload.cts b/App/shell/desktop/src/preload/preload.cts index e8ede4299..96d72fcf7 100644 --- a/App/shell/desktop/src/preload/preload.cts +++ b/App/shell/desktop/src/preload/preload.cts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer }: typeof import("electron") = require("electron"); +const { contextBridge, ipcRenderer, webUtils }: typeof import("electron") = require("electron"); type IpcRendererEvent = import("electron").IpcRendererEvent; type DesktopAppInfo = import("@memmy/desktop-interface").DesktopAppInfo; type DesktopUpdateCheckResult = import("@memmy/desktop-interface").DesktopUpdateCheckResult; @@ -8,6 +8,8 @@ type DesktopUpdateInstallResult = import("@memmy/desktop-interface").DesktopUpda type DesktopMenuBarIconResult = import("@memmy/desktop-interface").DesktopMenuBarIconResult; type DesktopImageActionRequest = import("@memmy/desktop-interface").DesktopImageActionRequest; type DesktopImageSaveResult = import("@memmy/desktop-interface").DesktopImageSaveResult; +type DesktopSystemFileIconResult = import("@memmy/desktop-interface").DesktopSystemFileIconResult; +type DesktopSystemFolderIconKind = import("@memmy/desktop-interface").DesktopSystemFolderIconKind; type DesktopMemoryServiceRestartResult = import("@memmy/desktop-interface").DesktopMemoryServiceRestartResult; type DesktopProjectDirectorySelection = import("@memmy/desktop-interface").DesktopProjectDirectorySelection; type MicrophoneAccessStatus = import("@memmy/desktop-interface").MicrophoneAccessStatus; @@ -34,6 +36,10 @@ interface MemmyPreloadApi { openMailto(mailtoUrl: string): Promise; copyImageToClipboard(request: DesktopImageActionRequest): Promise; saveImage(request: DesktopImageActionRequest): Promise; + getPathForFile(file: File): string; + getSystemFileIcon(filePath: string): Promise; + getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise; + showItemInFolder(filePath: string): Promise; exportMemoryDatabase(): Promise; installCliTools(): Promise; restartMemoryService(): Promise; @@ -158,6 +164,22 @@ const memmyPreloadApi: MemmyPreloadApi = { return ipcRenderer.invoke("memmy:save-image", request); }, + getPathForFile(file: File): string { + return webUtils.getPathForFile(file); + }, + + async getSystemFileIcon(filePath: string): Promise { + return ipcRenderer.invoke("memmy:get-system-file-icon", filePath); + }, + + async getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise { + return ipcRenderer.invoke("memmy:get-system-folder-icon", kind); + }, + + async showItemInFolder(filePath: string): Promise { + return ipcRenderer.invoke("memmy:show-item-in-folder", filePath); + }, + async notifyTaskDone(payload: { title: string; body: string; silent: boolean }): Promise { return ipcRenderer.invoke("memmy:notify-task-done", payload); }, diff --git a/App/shell/desktop/tests/main-window-action-buffer.test.ts b/App/shell/desktop/tests/main-window-action-buffer.test.ts index bf3cc32d5..70f8b89f4 100644 --- a/App/shell/desktop/tests/main-window-action-buffer.test.ts +++ b/App/shell/desktop/tests/main-window-action-buffer.test.ts @@ -10,6 +10,7 @@ interface MainWindowActionRequest { interface ExposedMemmyApi { platform: string; + getPathForFile(file: File): string; onMainWindowActionRequest(callback: (request: MainWindowActionRequest) => void): () => void; } @@ -40,6 +41,9 @@ function loadPreload(): { removeListener(channel: string, listener: (event: unknown, payload: unknown) => void): void { listeners.get(channel)?.delete(listener); } + }, + webUtils: { + getPathForFile: vi.fn(() => "/Users/example/report.docx") } }; const module = { exports: {} }; @@ -72,6 +76,13 @@ describe("main window action preload buffer", () => { expect(preload.memmy.platform).toBe(process.platform); }); + it("exposes the native local path for a renderer File", () => { + const preload = loadPreload(); + const file = new File(["report"], "report.docx"); + + expect(preload.memmy.getPathForFile(file)).toBe("/Users/example/report.docx"); + }); + it("delivers a close request that arrives before the renderer subscribes", () => { const preload = loadPreload(); const callback = vi.fn(); diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 62274a22f..6e34b0cb2 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -570,6 +570,30 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("if (response.status === 401)"); }); + it("exposes native operating-system file icons for real local files", () => { + const mainSource = readFileSync(mainSourcePath, "utf8"); + const preloadSource = readFileSync(preloadSourcePath, "utf8"); + const interfaceSource = readFileSync(desktopInterfacePath, "utf8"); + + expect(interfaceSource).toContain("export type DesktopSystemFileIconResult = string | null;"); + expect(preloadSource).toContain("getPathForFile(file: File): string;"); + expect(preloadSource).toContain("getSystemFileIcon(filePath: string): Promise;"); + expect(preloadSource).toContain("getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise;"); + expect(preloadSource).toContain("showItemInFolder(filePath: string): Promise;"); + expect(preloadSource).toContain("webUtils.getPathForFile(file)"); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:get-system-file-icon", filePath)'); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:get-system-folder-icon", kind)'); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:show-item-in-folder", filePath)'); + expect(mainSource).toContain('ipcMain.handle("memmy:get-system-file-icon"'); + expect(mainSource).toContain('ipcMain.handle("memmy:get-system-folder-icon"'); + expect(mainSource).toContain('ipcMain.handle("memmy:show-item-in-folder"'); + expect(mainSource).toContain("shell.showItemInFolder(target)"); + expect(mainSource).toContain('app.getFileIcon(filePath, { size: "large" })'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:get-system-file-icon")'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:get-system-folder-icon")'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:show-item-in-folder")'); + }); + it("installs memmy-memory into ~/.local/bin through the desktop bridge", () => { const mainSource = readFileSync(mainSourcePath, "utf8"); const preloadSource = readFileSync(preloadSourcePath, "utf8"); diff --git a/package-lock.json b/package-lock.json index 310d96da8..2d4bcbb07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,7 +555,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -577,7 +576,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -587,31 +585,10 @@ "node": ">=14.14" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2465,6 +2442,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", @@ -2768,6 +2776,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2778,6 +2787,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2887,6 +2897,7 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -3401,6 +3412,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3474,6 +3519,7 @@ "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", @@ -3724,6 +3770,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5024,8 +5071,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5104,6 +5150,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5384,6 +5431,7 @@ "integrity": "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", @@ -5591,7 +5639,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5612,7 +5659,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -5628,7 +5674,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5639,7 +5684,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -5924,6 +5968,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6094,6 +6139,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7447,6 +7493,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9900,7 +9947,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10563,6 +10609,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10713,7 +10760,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10731,7 +10777,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11044,6 +11089,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11053,6 +11099,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11461,7 +11508,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12544,7 +12590,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12952,6 +12997,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13159,6 +13205,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -13307,6 +13354,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -13385,6 +13433,7 @@ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", @@ -13800,6 +13849,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }