diff --git a/app/styles.css b/app/styles.css index 10bdd7b..f6d77bb 100644 --- a/app/styles.css +++ b/app/styles.css @@ -759,10 +759,24 @@ body.theme-dark .chip-button { display: flex; justify-content: flex-end; align-items: flex-start; - width: min(396px, calc(100vw - 24px)); + width: auto; pointer-events: none; } +.projects-ai-assistant-host.is-floating { + right: auto; +} + +.projects-ai-assistant-host.is-dragging, +body.projects-ai-dragging { + user-select: none; +} + +.projects-ai-assistant-host.is-dragging { + transition: none; + will-change: transform; +} + .projects-ai-launch { pointer-events: auto; align-self: flex-start; @@ -778,6 +792,9 @@ body.theme-dark .chip-button { 0 20px 40px rgba(15, 23, 42, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.88); transform: translateX(4px); + cursor: grab; + touch-action: none; + user-select: none; } .projects-ai-launch-avatar { @@ -799,6 +816,7 @@ body.theme-dark .chip-button { height: 100%; object-fit: cover; display: block; + -webkit-user-drag: none; } .projects-ai-panel { @@ -838,6 +856,10 @@ body.theme-dark .chip-button { background: linear-gradient(135deg, rgba(239, 246, 255, 0.98), rgba(255, 255, 255, 0.98)); } +.projects-ai-assistant-host.is-dragging .projects-ai-launch { + cursor: grabbing; +} + .projects-ai-panel-copy { display: inline-flex; margin-top: 4px; diff --git a/app/visualConsole/clientScript.ts b/app/visualConsole/clientScript.ts index 4eba256..dc3e9d8 100644 --- a/app/visualConsole/clientScript.ts +++ b/app/visualConsole/clientScript.ts @@ -74,6 +74,11 @@ export interface SideAssistantActiveMainResult { scope_key: string; } +export interface SideAssistantPlacementState { + left: number; + top: number; +} + export interface SideAssistantSearchSession { panel_open: boolean; messages: SideAssistantMessage[]; @@ -140,6 +145,36 @@ export function canRestoreSideAssistantMainResult(scopeKey: string | null | unde return Boolean(scopeKey) && scopeKey === currentScopeKey; } +export function normalizeSideAssistantPlacement(value: unknown): SideAssistantPlacementState | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const left = Number(record.left); + const top = Number(record.top); + if (!Number.isFinite(left) || !Number.isFinite(top)) return null; + return { left, top }; +} + +export function clampSideAssistantPlacement( + placement: SideAssistantPlacementState, + viewport: { width: number; height: number }, + bounds: { width: number; height: number }, + margin = 8, +): SideAssistantPlacementState { + const maxLeft = Math.max(margin, Math.floor(viewport.width - bounds.width - margin)); + const maxTop = Math.max(margin, Math.floor(viewport.height - bounds.height - margin)); + return { + left: Math.min(Math.max(Math.floor(placement.left), margin), maxLeft), + top: Math.min(Math.max(Math.floor(placement.top), margin), maxTop), + }; +} + +export function shouldStartSideAssistantDrag( + delta: { x: number; y: number }, + threshold = 4, +): boolean { + return Math.hypot(delta.x, delta.y) >= threshold; +} + export function resetSideAssistantConversation(session: SideAssistantSearchSession, keepPanelOpen = true): SideAssistantSearchSession { return { panel_open: keepPanelOpen, @@ -237,6 +272,9 @@ export function renderClientScriptSource(): string { const pickSideAssistantPreviewIds = ${pickSideAssistantPreviewIds.toString()}; const buildSideAssistantScopeKey = ${buildSideAssistantScopeKey.toString()}; const canRestoreSideAssistantMainResult = ${canRestoreSideAssistantMainResult.toString()}; + const normalizeSideAssistantPlacement = ${normalizeSideAssistantPlacement.toString()}; + const clampSideAssistantPlacement = ${clampSideAssistantPlacement.toString()}; + const shouldStartSideAssistantDrag = ${shouldStartSideAssistantDrag.toString()}; const createEmptySideAssistantSession = ${createEmptySideAssistantSession.toString()}; const normalizeSideAssistantSession = ${normalizeSideAssistantSession.toString()}; const directSearchLooksNaturalLanguage = ${directSearchLooksNaturalLanguage.toString()}; @@ -260,6 +298,7 @@ export function renderClientScriptSource(): string { return normalized.replace(/\\s+/g, "").length >= FUZZY_SEARCH_MIN_ENGLISH_CHARS; }; const sideAssistantSessionStorageKey = "visual-console-side-assistant-search"; + const sideAssistantPlacementStorageKey = "visual-console-side-assistant-placement-v4"; const readSideAssistantSession = () => { try { return normalizeSideAssistantSession(JSON.parse(sessionStorage.getItem(sideAssistantSessionStorageKey) || "null")); @@ -272,6 +311,188 @@ export function renderClientScriptSource(): string { sessionStorage.setItem(sideAssistantSessionStorageKey, JSON.stringify(session)); } catch {} }; + const readSideAssistantPlacement = () => { + try { + return normalizeSideAssistantPlacement(JSON.parse(sessionStorage.getItem(sideAssistantPlacementStorageKey) || "null")); + } catch { + return null; + } + }; + const writeSideAssistantPlacement = (placement) => { + try { + if (!placement) { + sessionStorage.removeItem(sideAssistantPlacementStorageKey); + return; + } + sessionStorage.setItem(sideAssistantPlacementStorageKey, JSON.stringify(placement)); + } catch {} + }; + const clearSideAssistantPlacement = () => writeSideAssistantPlacement(null); + const applySideAssistantPlacement = (assistantHost, boundsOverride) => { + if (!(assistantHost instanceof HTMLElement)) return null; + const placement = readSideAssistantPlacement(); + if (!placement) { + assistantHost.classList.remove("is-floating"); + assistantHost.style.left = ""; + assistantHost.style.top = ""; + assistantHost.style.right = ""; + assistantHost.style.transform = ""; + return null; + } + const rect = boundsOverride || assistantHost.getBoundingClientRect(); + const clamped = clampSideAssistantPlacement( + placement, + { width: window.innerWidth, height: window.innerHeight }, + { width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) }, + ); + assistantHost.classList.add("is-floating"); + assistantHost.style.left = clamped.left + "px"; + assistantHost.style.top = clamped.top + "px"; + assistantHost.style.right = "auto"; + assistantHost.style.transform = ""; + if (clamped.left !== placement.left || clamped.top !== placement.top) { + writeSideAssistantPlacement(clamped); + } + return clamped; + }; + const bindSideAssistantPlacement = ({ assistantHost, assistantLauncher, assistantPanel }) => { + if (!(assistantHost instanceof HTMLElement) || assistantHost.dataset.projectsAiPlacementBound === "true") return; + assistantHost.dataset.projectsAiPlacementBound = "true"; + const shouldSuppressAssistantClick = () => Number(assistantHost.dataset.projectsAiSuppressClickUntil || "0") > Date.now(); + let dragPointerId = null; + let dragHandle = null; + let dragOffsetX = 0; + let dragOffsetY = 0; + let dragBounds = null; + let dragPlacement = null; + let dragPendingPlacement = null; + let dragBaseLeft = 0; + let dragBaseTop = 0; + let dragFrameId = 0; + let dragStartClientX = 0; + let dragStartClientY = 0; + let dragMoved = false; + const renderDragFrame = () => { + dragFrameId = 0; + if (!dragPendingPlacement) return; + assistantHost.style.transform = "translate3d(" + String(dragPendingPlacement.left - dragBaseLeft) + "px, " + String(dragPendingPlacement.top - dragBaseTop) + "px, 0)"; + }; + const scheduleDragFrame = () => { + if (dragFrameId === 0) dragFrameId = requestAnimationFrame(renderDragFrame); + }; + const clearDrag = () => { + if (dragFrameId !== 0) cancelAnimationFrame(dragFrameId); + dragPointerId = null; + dragHandle = null; + dragBounds = null; + dragPlacement = null; + dragPendingPlacement = null; + dragBaseLeft = 0; + dragBaseTop = 0; + dragFrameId = 0; + dragStartClientX = 0; + dragStartClientY = 0; + dragMoved = false; + document.body?.classList.remove("projects-ai-dragging"); + assistantHost.classList.remove("is-dragging"); + }; + const startDrag = (event, handle) => { + if (!(handle instanceof HTMLElement)) return; + const interactiveTarget = event.target instanceof Element + ? event.target.closest("button, a, input, textarea, select, label, [data-projects-ai-option-query], [data-projects-ai-view-all='true'], [data-projects-ai-quickstart-toggle='true'], [data-projects-ai-project-link='true']") + : null; + if (interactiveTarget && interactiveTarget !== handle) return; + if (event.button !== 0 || event.isPrimary === false) return; + const hostBounds = assistantHost.getBoundingClientRect(); + dragBounds = { width: Math.max(1, Math.round(hostBounds.width)), height: Math.max(1, Math.round(hostBounds.height)) }; + dragPlacement = applySideAssistantPlacement(assistantHost, hostBounds) || { + left: Math.round(hostBounds.left), + top: Math.round(hostBounds.top), + }; + dragBaseLeft = dragPlacement.left; + dragBaseTop = dragPlacement.top; + dragPointerId = event.pointerId; + dragHandle = handle; + dragStartClientX = event.clientX; + dragStartClientY = event.clientY; + dragOffsetX = event.clientX - hostBounds.left; + dragOffsetY = event.clientY - hostBounds.top; + dragHandle?.setPointerCapture?.(event.pointerId); + }; + const launcherHandle = assistantLauncher instanceof HTMLElement ? assistantLauncher : null; + const panelHandle = assistantPanel instanceof HTMLElement ? assistantPanel.querySelector(".projects-ai-panel-head") : null; + launcherHandle instanceof HTMLElement && + launcherHandle.addEventListener("pointerdown", (event) => { + if (assistantPanel instanceof HTMLElement && !assistantPanel.hidden) return; + startDrag(event, launcherHandle); + }); + launcherHandle instanceof HTMLElement && launcherHandle.addEventListener("dragstart", (event) => event.preventDefault()); + const restoreDefaultPlacement = () => { + clearSideAssistantPlacement(); + applySideAssistantPlacement(assistantHost); + }; + panelHandle instanceof HTMLElement && panelHandle.addEventListener("dblclick", restoreDefaultPlacement); + document.addEventListener("pointermove", (event) => { + if (dragPointerId !== event.pointerId || !dragBounds) return; + if ( + !dragMoved && + !shouldStartSideAssistantDrag({ x: event.clientX - dragStartClientX, y: event.clientY - dragStartClientY }) + ) { + return; + } + if (!dragMoved) { + dragMoved = true; + document.body?.classList.add("projects-ai-dragging"); + assistantHost.classList.add("is-dragging"); + assistantHost.classList.add("is-floating"); + assistantHost.style.left = dragBaseLeft + "px"; + assistantHost.style.top = dragBaseTop + "px"; + assistantHost.style.right = "auto"; + assistantHost.style.transform = ""; + } + dragPlacement = clampSideAssistantPlacement( + { left: event.clientX - dragOffsetX, top: event.clientY - dragOffsetY }, + { width: window.innerWidth, height: window.innerHeight }, + dragBounds, + ); + dragPendingPlacement = dragPlacement; + scheduleDragFrame(); + event.preventDefault(); + }); + const stopDrag = (event) => { + if (dragPointerId !== event.pointerId) return; + if (dragMoved && dragPlacement) { + assistantHost.dataset.projectsAiSuppressClickUntil = String(Date.now() + 800); + assistantHost.style.transform = ""; + assistantHost.style.left = dragPlacement.left + "px"; + assistantHost.style.top = dragPlacement.top + "px"; + assistantHost.style.right = "auto"; + writeSideAssistantPlacement(dragPlacement); + event.preventDefault(); + event.stopPropagation(); + } + dragHandle?.releasePointerCapture?.(event.pointerId); + clearDrag(); + }; + document.addEventListener("pointerup", stopDrag); + document.addEventListener("pointercancel", stopDrag); + window.addEventListener("resize", () => { + applySideAssistantPlacement(assistantHost); + }); + launcherHandle instanceof HTMLElement && + launcherHandle.addEventListener("click", (event) => { + if (!shouldSuppressAssistantClick()) return; + event.preventDefault(); + event.stopPropagation(); + }, true); + panelHandle instanceof HTMLElement && + panelHandle.addEventListener("click", (event) => { + if (!shouldSuppressAssistantClick()) return; + event.preventDefault(); + event.stopPropagation(); + }, true); + applySideAssistantPlacement(assistantHost); + }; const syncSideAssistantShell = () => { const session = readSideAssistantSession(); const assistantHost = document.querySelector("[data-projects-ai-assistant-host='true']"); @@ -297,6 +518,9 @@ export function renderClientScriptSource(): string { session.panel_open = isOpen; writeSideAssistantSession(session); syncSideAssistantShell(); + if (isOpen) { + applySideAssistantPlacement(document.querySelector("[data-projects-ai-assistant-host='true']")); + } }; const scrollKey = "visual-console-scroll-target"; @@ -1534,9 +1758,11 @@ export function renderClientScriptSource(): string { setAssistantStatus(assistantPending ? "正在搜索站内项目库…" : ""); }; const openAssistantPanel = () => { + if (Number(assistantHost?.dataset.projectsAiSuppressClickUntil || "0") > Date.now()) return; assistantSession.panel_open = true; writeSideAssistantSession(assistantSession); renderAssistantPanel(); + applySideAssistantPlacement(assistantHost); }; const closeAssistantPanel = () => { assistantSession.panel_open = false; @@ -1687,6 +1913,7 @@ export function renderClientScriptSource(): string { navigateToProjectsResults(viewAll.dataset.projectsAiViewAllQuery || assistantSession.search_context?.last_user_query || "AI 搜项目", projectIds); } }); + bindSideAssistantPlacement({ assistantHost, assistantLauncher, assistantPanel }); renderAssistantPanel(); }; const bindProjectsWorkbench = () => { @@ -1894,9 +2121,11 @@ export function renderClientScriptSource(): string { return target.toString(); }; const openAssistantPanel = () => { + if (Number(assistantHost?.dataset.projectsAiSuppressClickUntil || "0") > Date.now()) return; assistantSession.panel_open = true; writeAssistantSession(); renderAssistantPanel(); + applySideAssistantPlacement(assistantHost); }; const closeAssistantPanel = () => { assistantSession.panel_open = false; @@ -2455,6 +2684,7 @@ export function renderClientScriptSource(): string { apply(); } }); + bindSideAssistantPlacement({ assistantHost, assistantLauncher, assistantPanel }); previousPageButtons.forEach((button) => { button.addEventListener("click", () => { if (button.disabled) return; @@ -2771,6 +3001,8 @@ export function renderClientScriptSource(): string { const target = event.target; if (!(target instanceof Element)) return; if (target.closest("[data-projects-ai-launch='true']")) { + const assistantHost = document.querySelector("[data-projects-ai-assistant-host='true']"); + if (Number(assistantHost?.dataset.projectsAiSuppressClickUntil || "0") > Date.now()) return; setSideAssistantPanelOpen(true); return; } diff --git a/app/visualConsole/ossProjectsPage.ts b/app/visualConsole/ossProjectsPage.ts index d593682..bd9e4dc 100644 --- a/app/visualConsole/ossProjectsPage.ts +++ b/app/visualConsole/ossProjectsPage.ts @@ -554,7 +554,7 @@ export function renderProjectsAssistantShell(lang: UiLang): string { aria-label="${escapeHtml(uiText(lang, "打开 AI 搜项目", "Open AI Project Search"))}" >
open_idle -> submitting -> success_preview -> clarification -> empty_or_hot_fallback -> error` 状态机 | Phase 2 | `clientScript.ts` | 成功/澄清/空/失败都可见且不空白 | +| `sessionStorage` 会话记忆与 `SideAssistantSearchSession` | Phase 2 | `clientScript.ts` | 刷新恢复会话;关闭标签页后自然丢失 | +| 最多 3 条预览链接与 `more` 去重 | Phase 2 | `clientScript.ts` | 不重复 `seen_project_ids`,无新项时明确提示 | +| `查看全部结果` 切主列表,`返回普通项目库` 只清结果态 | Phase 3 | `clientScript.ts`、`ossProjectsPage.ts` | 主列表完全切换 / 结果态可撤销 / 对话保留 | +| `Projects` 页内开详情,非 `Projects` 页跳转承接 | Phase 3 | `clientScript.ts` | 点击结果后助手不丢失,URL 不写入对话 | +| 拖动定位与 `SideAssistantPlacementState` 独立存储 | Phase 4 | `clientScript.ts` | 同一 host、只存 `{ left, top }`、位置始终 clamp 在视口内 | +| scope 匹配才恢复 `active_main_result` | Phase 4 | `clientScript.ts` | 切换 `date/lang/filters/sort/path` 后不会错误恢复旧结果 | + +## 范围边界 -### 建议修改 +### 允许修改 +- `app/visualConsole/clientScript.ts` - `app/visualConsole/ossPages.ts` - - 维持 assistant host 的全局挂载位置,确保所有一级视图都能看到并打开入口。 - `app/visualConsole/ossProjectsPage.ts` - - 只保留 `Projects` 结果承接 DOM 和主搜索框无结果时打开 `AI 搜项目` 的引导。 -- `app/visualConsole/clientScript.ts` - - 删除或禁用主搜索框 zero-result 自动 fuzzy 触发。 - - 把助手 runtime 从 `Projects` workbench 初始化里拆出来,避免 `if (!(search instanceof HTMLInputElement)) return;` 让其他页面入口失活。 - - 新增助手打开/关闭、提交、请求、预览、查看全部结果、返回普通项目库、项目链接分流和 sessionStorage 恢复逻辑。 - - 复用现有 project card records、detail link/dock flow、`/api/projects/fuzzy-search` endpoint。 +- `app/styles.css` - `src/__tests__/visualConsoleFuzzySearch.test.ts` - - 更新旧 fuzzy 入口测试,证明 endpoint 只由助手触发。 - - 增加主搜索框无结果不触发 fuzzy、助手提交触发 fuzzy、结果最多 3 条链接、查看全部结果切主列表、返回普通项目库不清对话等守护。 +- `src/__tests__/visualConsoleSearchAlignment.test.ts` +- 必要时新增一个只覆盖侧边助手行为的测试文件 +- 本 exec-plan 文档与 `docs/specs/exec-plans/README.md` -## 当前状态 +### 禁止修改 -当前仓库已经具备: +- 不新增 `/api/projects/fuzzy-search` 之外的搜索 endpoint。 +- 不扩展 `FuzzyProjectSearchResponse`、LLM prompt schema 或服务端 fuzzy contract。 +- 不修改 `src/search/fuzzyQueryTypes.ts`、`app/server.ts` 的 fuzzy endpoint 语义。 +- 不让 LLM 生成项目事实、项目链接、项目排序或站外建议。 +- 不把助手做成通用聊天问答、第二套项目列表、面板内分页或复杂筛选器。 +- 不把拖动实现成第二套 overlay/portal 系统,不引入第三方拖拽库。 +- 不把会话、`conversation_id`、完整 `project_ids` 或长自然语言上下文写进 URL。 +- 不引入 `localStorage`、账号同步、跨设备记忆或数据库会话表。 -- `/api/projects/fuzzy-search` endpoint。 -- 主项目搜索框 zero-result fuzzy 自动触发逻辑。 -- 项目卡片搜索 metadata 与 `project_ids -> cards` 映射能力。 -- 项目卡链接和当前页详情打开能力。 -- `sessionStorage` 页面级临时状态读写先例。 +### 兼容性约束 -当前缺口: +- 一级路由保持现有 `Overview / Weekly / Observer / KB / Run Health / Projects` 体系不变。 +- `Projects` 页既有 detail dock、`data-project-card-link="true"` 和详情 URL 语义保持可用。 +- 普通搜索框原有关键词过滤继续工作;只是去掉隐式 fuzzy fallback。 +- `/api/projects/fuzzy-search` 继续只消费现有请求字段、只返回现有响应字段。 -- fuzzy 仍挂在主搜索框 zero-result 路径上。 -- 没有 `AI 搜项目` 独立助手入口。 -- 没有助手会话状态、预览链接、多轮上下文和主列表 AI 结果态。 +## 当前状态 + +- 上游设计已经明确冻结:`AI 搜项目` 是唯一自然语言入口,结果承接只认 `Projects`,V1 记忆只用 `sessionStorage`,拖动位置与会话状态分开保存。 +- 仓库已有 `/api/projects/fuzzy-search`、`project_ids -> 项目卡` 映射、项目卡链接/dock 打开链路,以及页面级 `sessionStorage` 先例。 +- 仓库中已出现侧边助手相关代码与测试基线,但尚未把它们当作“已通过最新设计冻结”的证明;本计划仍以设计冻结项为验收真源。 +- 当前最大实现风险不是“能力不存在”,而是旧入口、旧挂载位置、旧浮层心智、拖动契约和恢复边界没有被统一写成可执行账本。 ## 当前进度 - 需求分析:`Done` -- 设计冻结:`Done`(`side-assistant-natural-language-search-design.md` 已进入 `Approved for ExecPlan`) -- 执行计划:`Proposed` +- 设计冻结:`Done` +- 执行计划:`In Progress` - 实施:`Not Started` - 验证:`Not Started` ## 已落地内容 -- 已完成需求文档与设计文档。 -- 已冻结 `AI 搜项目` 命名、主搜索框剥离规则、`返回普通项目库` 状态语义。 -- 已确认本计划不需要新增后端搜索能力。 +- 已完成需求与设计文档,明确本计划不需要新后端能力。 +- 已冻结 `AI 搜项目` 命名、主搜索框剥离规则、会话作用域、结果承接面、拖动位置存储规则和 URL 边界。 +- 已确认本计划的主要实现面集中在 `clientScript.ts + ossPages.ts + ossProjectsPage.ts + app/styles.css + tests`,不需要扩散到搜索服务端或 LLM schema。 ## 实施阶段 | 阶段 | 状态 | 目标 | 完成标志 | | --- | --- | --- | --- | -| Phase 0:剥离主搜索框 fuzzy | `Not Started` | 主搜索框只做关键词过滤,无结果只展示引导 | 普通无结果不调用 `/api/projects/fuzzy-search` | -| Phase 1:助手壳层 | `Not Started` | 增加 `AI 搜项目` 收起入口、展开面板和基础样式 | 可打开/关闭,状态可持久化 | -| Phase 2:助手请求与预览 | `Not Started` | 助手提交调用 fuzzy endpoint,展示短回复和最多 3 条链接 | 成功/澄清/空/失败都有用户可见状态 | -| Phase 3:主区联动 | `Not Started` | 点项目开详情、查看全部结果切主列表、返回普通项目库 | 助手不卸载,会话不丢 | -| Phase 4:恢复与移动降级 | `Not Started` | 刷新恢复会话和有效结果态,移动端退化为抽屉/轻面板 | scope 不匹配时不恢复旧结果 | -| Phase 5:验证收口 | `Not Started` | 补齐目标测试、typecheck 和手动 smoke | 目标测试与 typecheck 通过 | +| Phase 0:剥离旧入口与冻结边界 | `Not Started` | 主搜索框退出 fuzzy 路径,明确模块与状态边界 | 普通无结果不调 fuzzy;执行面与禁改面固定 | +| Phase 1:全局 host 与抽屉壳层 | `Not Started` | 助手变成所有一级视图共享的右侧服务抽屉 | 入口全局可用;不再依赖 `Projects` 初始化 | +| Phase 2:会话状态机与搜索预览 | `Not Started` | 落地 `sessionStorage`、query composition、最多 3 条预览与失败降级 | 成功/澄清/空/失败都可见;`more` 不重复 | +| Phase 3:主区联动与结果承接 | `Not Started` | 页内开详情、跨页跳转、查看全部结果、返回普通项目库 | 主列表切换可恢复;对话不丢 | +| Phase 4:拖动定位与恢复边界 | `Not Started` | 落地拖动契约、placement 存储、scope 恢复与移动端退化 | 位置独立恢复;scope 不匹配不恢复旧结果 | +| Phase 5:验证与账本收口 | `Not Started` | 补齐测试、preflight、typecheck 和 smoke | 设计冻结项全部有验证证据 | ## 实施步骤 -### Phase 0:剥离主搜索框 fuzzy - -1. 在 `clientScript.ts` 找到当前 `syncFuzzySearch/startFuzzySearch` 与主搜索框 `apply()` 的耦合点。 -2. 主搜索框路径保留 direct keyword filter 和普通 empty state。 -3. 主搜索框无结果时只显示 `AI 搜项目` 引导,不调 `/api/projects/fuzzy-search`。 -4. 保留 fuzzy endpoint helper 能力,但调用入口改由助手提交触发。 -5. 更新 `visualConsoleFuzzySearch.test.ts` 中旧的主搜索框 fuzzy 断言。 - -### Phase 1:助手壳层 - -1. assistant host 保持挂在全局 shell,不再依赖 `Projects` 局部 DOM 生命周期。 -2. 收起态显示右侧 `AI 搜项目` 入口,所有一级视图都可打开。 -3. 展开态显示标题 `AI 搜项目`、关闭按钮、输入框、`新对话` 和快捷问题卡片。 -4. 桌面端改为参考华为云 `云宝助手` 的右侧全高服务抽屉,不再做遮挡正文的短浮层。 -5. 面板宽度桌面端控制在设计冻结的 `360px - 420px`。 -6. 移动端先使用同一 DOM 退化为底部/全屏轻面板,不新增第二套组件。 - -### Phase 2:助手请求与预览 - -1. 定义最小 `SideAssistantSearchSession` 读写函数,存入 `sessionStorage`。 -2. 助手提交时先生成 `composed_query`,请求体使用 `raw_query: composed_query` 调用 `/api/projects/fuzzy-search`,面板只展示用户原始输入。 -3. 多轮追问在前端组合 `composed_query`,不新增后端 conversation API。 -4. 成功时从 `project_ids` 映射本地项目记录,最多展示 3 条项目链接。 -5. `more` 模式排除 `seen_project_ids`,没有新预览时说明没有更多未展示项目。 -6. `needs_clarification` 展示 2-3 个可点击改写选项。 -7. endpoint 失败、空结果、敏感边界都保留用户输入和当前上下文。 - -### Phase 3:主区联动 - -1. 助手项目链接复用现有 project card href/detail flow。 -2. 如果当前就在 `Projects` 页,点击链接后在当前页打开详情,助手 host 不卸载。 -3. 如果当前不在 `Projects` 页,点击链接后跳转到 `Projects` 对应位置,并通过 sessionStorage 恢复当前会话。 -4. 点击 `查看全部结果` 后,将主列表完全切换为本轮 `project_ids` 顺序。 -5. 写入 `active_main_result`,状态条显示 `AI 助手结果`、本轮 query 和 `返回普通项目库`。 -6. 点击 `返回普通项目库` 只清 `active_main_result` 和状态条,保留 `messages/search_context`。 -7. 点击 `新对话` 清空 `messages/search_context/active_main_result`,并恢复默认项目库态。 - -### Phase 4:恢复与移动降级 - -1. 页面加载时恢复 `panel_state/messages/search_context`。 -2. 仅当 `active_main_result.scope_key` 匹配当前 `date/lang/filters/sort/path` 时恢复 AI 结果态。 -3. scope 不匹配时清空 `active_main_result`,保留会话消息。 -4. 移动端点击项目链接后允许助手自动收起,但会话必须保留。 -5. `sessionStorage` 不可用时本轮仍可使用,只是不保证刷新恢复。 - -### Phase 5:验证收口 - -1. 运行目标测试: - - `npm.cmd test -- visualConsoleFuzzySearch` -2. 新增或扩展助手行为测试,覆盖: - - 所有一级视图都能打开 `AI 搜项目`。 - - 多轮 `raw_query` composition。 - - `more` 不重复 `seen_project_ids`。 - - 非 `Projects` 页点击结果会跳转到 `Projects` 承接。 - - `active_main_result.scope_key` 不匹配时不恢复旧结果。 - - `新对话` 清空 `messages/search_context/active_main_result`。 - - 敏感边界提示可见。 -3. 运行类型检查: - - `npm.cmd run typecheck` -4. 如改动触及项目详情或搜索 kernel,追加: - - `npm.cmd test -- visualConsoleSearchAlignment fuzzyProjectSearch` -5. 手动 smoke: - - 主搜索框输入无结果查询,不应发起 fuzzy 请求。 - - `AI 搜项目` 输入自然语言,展示最多 3 条链接。 - - 点击项目链接后详情打开且助手仍在。 - - 点击 `查看全部结果` 后主列表完全切换。 - - 点击 `返回普通项目库` 后刷新不恢复旧 AI 结果态。 +### Phase 0:剥离旧入口与冻结边界 + +1. 在 `clientScript.ts` 找到当前主搜索框 `apply()`、zero-result 处理和 `/api/projects/fuzzy-search` 的耦合点,移除“普通搜索无结果自动进入 fuzzy”的旧路径。 +2. 在 `ossProjectsPage.ts` 保留普通空结果态与 `AI 搜项目` 引导,但不再由主搜索框直接发起 fuzzy 请求。 +3. 固定本计划的状态与存储边界: + - 搜索会话使用 `SideAssistantSearchSession` + - 拖动位置使用独立 `SideAssistantPlacementState` + - `active_main_result` 只负责主列表结果态恢复 +4. 固定本计划的文件归属: + - `ossPages.ts` 负责全局 host 挂载位 + - `clientScript.ts` 负责 runtime、状态机、请求、拖动、恢复与联动 + - `ossProjectsPage.ts` 只负责 `Projects` 承接面和普通搜索引导 +5. 补一条回归断言:普通搜索框无结果时,网络层不再出现 `/api/projects/fuzzy-search`。 + +### Phase 1:全局 host 与抽屉壳层 + +1. 把助手 host 挂到 app shell/body 级,而不是 `.primary-content`、项目列表或 `.detail-column` 内。 +2. 把助手 runtime 从 `Projects` workbench 初始化中拆出,解决“其他页面看得到入口但点了没反应”的根因。 +3. 收起态实现为右侧窄入口;展开态实现为右侧全高服务抽屉,桌面宽度保持设计冻结的 `360px - 420px`。 +4. 面板结构固定为五块: + - 头部:标题、状态说明、`新对话`、收起/关闭 + - 欢迎区:一句说明与 2-4 个快捷问题卡 + - 对话区:用户提问、助手短回复、项目预览、边界提示 + - 输入区:输入框、发送按钮 + - 结果动作:`查看全部结果` +5. 在 `app/styles.css` 落地抽屉样式所有权: + - 右侧全高抽屉与贴边收起入口 + - 桌面端 `360px - 420px` 宽度约束 + - 拖动后固定尺寸浮层态 + - pointer-events、cursor、user-select 与可见层级 + - 移动端底部抽屉 / 轻面板退化 +6. 首轮实现只复用现有 host,不新建 portal、overlay manager 或第二套浮层组件。 + +### Phase 2:会话状态机与搜索预览 + +1. 用 `sessionStorage` 实现 `SideAssistantSearchSession` 的读写、schema 归一化和异常兜底。 +2. 按设计冻结实现状态机: + - `collapsed` + - `open_idle` + - `submitting` + - `success_preview` + - `clarification` + - `empty_or_hot_fallback` + - `error` +3. 助手提交时只展示用户原始输入,发送给 `/api/projects/fuzzy-search` 的是 `composed_query`。 +4. 落地四类追问: + - `new_search` + - `refine` + - `more` + - `switch` +5. 成功态只从 `project_ids` 映射现有项目记录,最多展示 3 条链接;映射不到就跳过,不补造项目。 +6. `more` 模式必须排除 `seen_project_ids`;如果没有新项,明确告诉用户“这轮没有更多未展示项目”,但保留 `查看全部结果`。 +7. `needs_clarification`、空结果、endpoint 失败、`boundary_message_cn` 都必须转成用户可见状态,不允许静默失败或清空上下文。 + +### Phase 3:主区联动与结果承接 + +1. 助手预览链接复用现有 project card href / detail flow,不生成新详情页。 +2. 当前就在 `Projects` 页时: + - 点击链接后页内打开详情 + - 助手 host 继续挂载 + - 会话继续保留 +3. 当前不在 `Projects` 页时: + - 跳转到 `Projects` 对应位置 + - 保留现有 `date/lang/theme` 等最小路由上下文 + - 从 `sessionStorage` 恢复会话 + - 打开目标项目详情 +4. 点击 `查看全部结果` 后: + - 主列表完全切换为本轮 `project_ids` 顺序 + - 页面出现 `AI 助手结果` 状态条 + - `active_main_result` 写入 `sessionStorage` +5. 点击 `返回普通项目库` 后: + - 只清 `active_main_result` + - 主列表恢复默认项目库态 + - `messages` 与 `search_context` 保留 +6. 点击 `新对话` 后: + - 清空 `messages / search_context / active_main_result` + - abort 当前请求 + - 主列表回到默认项目库态 + +### Phase 4:拖动定位与恢复边界 + +1. 拖动只作用于同一个 assistant host;收起态入口和展开态面板共享同一位置源。 +2. 使用 Pointer Events 实现拖动,不维护 mouse/touch 两套分支。 +3. 触发约束必须与设计一致: + - 收起态只允许入口触发拖动 + - 展开态只允许面板头部触发拖动 + - 输入框、按钮、快捷卡、项目链接、`查看全部结果` 不得误触拖动 +4. 拖动后只保存 `{ left, top }`;不把路径、查询、项目结果或对话内容写进 placement state。 +5. 位置结束后必须 clamp 到当前视口内;窗口尺寸变化后必须重新 clamp。 +6. 双击拖动区域恢复默认右侧贴边位置。 +7. 页面恢复时: + - 始终可恢复 `panel_state/messages/search_context` + - 只有 `scope_key` 匹配当前 `date/lang/filters/sort/path` 时才恢复 `active_main_result` + - scope 不匹配时清掉旧结果态,但保留对话 +8. 移动端只要求退化为底部抽屉或轻面板,不要求展开态可拖;点击结果后允许自动收起,但对话必须保留。 + +### Phase 5:验证与账本收口 + +1. 更新/补充 DOM 与行为测试,确保每个冻结机制都有至少一个自动化验证锚点。 +2. 跑 exec-plan 结构 preflight、目标测试、类型检查;如相关回归会被触发,再追加普通搜索和 fuzzy endpoint 的回归测试。 +3. 手动 smoke 至少覆盖: + - 所有一级视图打开入口 + - 自然语言搜索成功态 + - `more` 去重 + - `Projects` 页页内开详情 + - 非 `Projects` 页跳转承接 + - `查看全部结果` + - `返回普通项目库` + - 双击恢复默认位置 + - resize 后 re-clamp + - 移动端点结果后自动收起但会话保留 + - 拖动、刷新恢复、scope mismatch +4. 更新本计划的验证记录、结论记录和 `README` 索引状态,不把它留成一次性草稿。 ## 验收标准 -1. 主搜索框已有命中时按关键词过滤,不显示 AI 解释。 -2. 主搜索框无结果时不调用 `/api/projects/fuzzy-search`。 -3. 主搜索框无结果时展示普通空状态和 `AI 搜项目` 引导。 -4. 只有 `AI 搜项目` 提交自然语言时才调用 `/api/projects/fuzzy-search`。 -5. 助手成功态最多展示 3 条项目链接,不渲染完整项目列表。 -6. 在 `Projects` 页点击助手项目链接会在当前页打开详情,助手仍可见且对话保留。 -7. 在非 `Projects` 页点击助手项目链接会跳转到 `Projects` 对应位置。 -8. 点击 `查看全部结果` 后主列表完全切换为本轮 AI 结果集,不混排默认列表。 -9. 点击 `返回普通项目库` 后刷新不会恢复旧 AI 结果态。 -10. 点击 `新对话` 清空会话并恢复默认项目库态。 -11. 敏感领域查询仍展示站内项目搜索边界提示。 +### 验收 1:自然语言入口唯一化成立 + +- 可观测结果:主搜索框继续做关键词过滤;只有 `AI 搜项目` 才会触发 `/api/projects/fuzzy-search`。 +- 失败条件:普通搜索无结果仍隐式触发 fuzzy,或出现两个自然语言入口并存。 + +### 验收 2:全局入口与助手心智成立 + +- 可观测结果:所有一级视图都能打开同一个右侧助手,且它是“轻量预览 + Projects 承接”而不是第二套项目库。 +- 失败条件:助手仍绑死在 `Projects` 生命周期上,或面板直接膨胀成完整列表页。 + +### 验收 3:多轮预览语义成立 + +- 可观测结果:助手每轮只展示短说明、最多 3 条链接、`查看全部结果`,`more` 不重复上一轮已预览项目。 +- 失败条件:展示完整列表、重复旧链接冒充新结果,或把 `composed_query` 原样暴露给用户。 + +### 验收 4:Projects 承接语义成立 + +- 可观测结果:`Projects` 页点链接优先页内开详情;其他页点链接跳到 `Projects` 对应位置;会话不丢。 +- 失败条件:新开独立详情页、当前页点链接无效、跳转后助手消失,或 URL 泄露对话。 + +### 验收 5:结果态恢复边界成立 + +- 可观测结果:`查看全部结果` 会切主列表并可刷新恢复;`返回普通项目库` 只清结果态;scope 不匹配不恢复旧结果。 +- 失败条件:刷新后旧结果误恢复,或返回普通项目库时连对话一起丢失。 + +### 验收 6:拖动契约成立 + +- 可观测结果:入口和面板共享同一位置;拖动不误伤交互控件;位置始终在视口内;双击可恢复默认位置。 +- 失败条件:点击按钮触发拖动、位置跑出屏幕、placement 与会话混存,或移动端因为拖动让输入区不可用。 + +## 关键负例 + +- 主搜索框无结果仍发起 `/api/projects/fuzzy-search`,判定失败。 +- 助手入口只能在 `Projects` 页响应,判定失败。 +- 助手在 `Projects` 详情联动时被卸载,判定失败。 +- `more` 模式重复展示 `seen_project_ids` 中的项目,判定失败。 +- 预览链接映射不到本地项目时仍生成假链接,判定失败。 +- `查看全部结果` 后主列表与默认项目库混排,判定失败。 +- `返回普通项目库` 后刷新仍恢复旧 AI 结果态,判定失败。 +- `conversation_id`、完整对话或完整 `project_ids` 出现在 URL 中,判定失败。 +- 输入框、发送按钮、链接、快捷卡片会误触拖动,判定失败。 +- 拖动位置保存在 `SideAssistantSearchSession` 里,导致新对话或 schema 修复误伤 placement,判定失败。 ## 验证矩阵 -| 类型 | 验证项 | 命令/方式 | 成功标准 | +| 类型 | 验证内容 | 命令 / 方法 | 通过标准 | | --- | --- | --- | --- | -| 前端行为 | 主搜索框剥离 fuzzy | `npm.cmd test -- visualConsoleFuzzySearch` | 无结果不 fetch fuzzy endpoint | -| 前端行为 | 助手提交 fuzzy | `npm.cmd test -- visualConsoleFuzzySearch` | 只有助手提交 fetch fuzzy endpoint | -| 前端行为 | 多轮 query composition | `npm.cmd test -- visualConsoleFuzzySearch` | 请求体 `raw_query` 使用 `composed_query` | -| 前端行为 | more 不重复预览 | `npm.cmd test -- visualConsoleFuzzySearch` | 不重复 `seen_project_ids` | -| 前端行为 | 预览链接上限 | `npm.cmd test -- visualConsoleFuzzySearch` | 最多 3 条链接 | -| 前端行为 | 查看全部结果 | `npm.cmd test -- visualConsoleFuzzySearch` | 主列表完全替换为 AI 结果 | -| 前端行为 | 返回普通项目库 | `npm.cmd test -- visualConsoleFuzzySearch` | 清结果态不清对话,刷新不恢复旧结果 | -| 前端行为 | scope mismatch 恢复 | `npm.cmd test -- visualConsoleFuzzySearch` | scope 不匹配时不恢复旧 AI 结果 | -| 前端行为 | 新对话 | `npm.cmd test -- visualConsoleFuzzySearch` | 清空会话并恢复默认项目库态 | -| 前端行为 | 敏感边界 | `npm.cmd test -- visualConsoleFuzzySearch` | 边界提示可见 | -| 回归 | 普通搜索对齐 | `npm.cmd test -- visualConsoleSearchAlignment` | 关键词搜索能力不退化 | -| 回归 | fuzzy endpoint contract | `npm.cmd test -- fuzzyProjectSearch` | endpoint schema 和降级路径不变 | -| 类型 | TypeScript | `npm.cmd run typecheck` | 无类型错误 | -| 手动 | visual-console smoke | `npm.cmd run visual-console:web` | 页面可用,助手流程可走通 | +| 计划 | ExecPlan 结构 preflight | `npm.cmd run exec-plan:preflight` | 计划结构与索引规则通过 | +| 类型 | 目标模块类型检查 | `npm.cmd run typecheck` | 无新增类型错误 | +| 行为 | 主搜索框剥离 fuzzy | `npm.cmd test -- visualConsoleFuzzySearch` | 普通无结果不 fetch fuzzy endpoint | +| 行为 | 全局入口可用 | `npm.cmd test -- visualConsoleFuzzySearch` | 所有一级视图都能打开助手 | +| 行为 | query composition | `npm.cmd test -- visualConsoleFuzzySearch` | `refine / more / switch` 使用冻结规则 | +| 行为 | 预览上限与去重 | `npm.cmd test -- visualConsoleFuzzySearch` | 最多 3 条链接,`more` 不重复 `seen_project_ids` | +| 行为 | 项目详情联动 | `npm.cmd test -- visualConsoleFuzzySearch` | `Projects` 页页内开详情,非 `Projects` 页跳转承接 | +| 行为 | 查看全部结果 / 返回普通项目库 | `npm.cmd test -- visualConsoleFuzzySearch` | 主列表切换与撤销语义正确 | +| 行为 | scope 恢复 | `npm.cmd test -- visualConsoleFuzzySearch` | scope 不匹配时不恢复旧结果 | +| 行为 | placement 触发区 | `npm.cmd test -- visualConsoleFuzzySearch` | 仅入口/面板头部允许拖动,输入框/按钮/链接/快捷卡不误触 | +| 行为 | placement 双击恢复 | `npm.cmd test -- visualConsoleFuzzySearch` | 双击拖动区域后恢复默认右侧贴边位置 | +| 行为 | placement resize re-clamp | `npm.cmd test -- visualConsoleFuzzySearch` | 窗口尺寸变化后位置仍留在可见视口内 | +| 行为 | 移动端自动收起与会话保留 | `npm.cmd test -- visualConsoleFuzzySearch` | 移动端点结果后允许收起,但消息和上下文不丢 | +| 回归 | 普通搜索关键词过滤 | `npm.cmd test -- visualConsoleSearchAlignment` | 普通搜索能力不退化 | +| 回归 | fuzzy endpoint contract | `npm.cmd test -- fuzzyProjectSearch` | schema 与降级路径不变 | +| 冒烟 | 浏览器手测 | `npm.cmd run visual-console:web` | 助手流程、拖动、详情联动、结果承接可走通 | ## 验证记录 | 日期 | 验证项 | 状态 | 记录 | | --- | --- | --- | --- | -| 2026-06-24 | ExecPlan 创建 | 已完成 | 新增本计划,尚未实施代码 | -| 2026-06-24 | 设计门禁确认 | 已完成 | 上游设计状态更新为 `Approved for ExecPlan` | +| 2026-06-27 | ExecPlan 改写 | 已完成 | 按最新设计冻结项重写计划,补齐拖动、scope、结果承接和负例 | +| 2026-06-27 | 上游设计门禁确认 | 已完成 | `side-assistant-natural-language-search-design.md` 当前状态为 `Approved for ExecPlan` | -## 回滚策略 +## 风险与缓解 -1. Phase 0 失败时: - - 保留主搜索框 direct keyword filter。 - - 禁用助手入口,不恢复主搜索框自动 fuzzy。 -2. Phase 1/2 失败时: - - 隐藏 `AI 搜项目` host。 - - endpoint 继续保留给后续实现使用。 -3. Phase 3 失败时: - - 暂时禁用 `查看全部结果` 和助手链接拦截。 - - 保留助手预览和普通项目库。 -4. 任意阶段不得修改 fuzzy endpoint contract 或删除现有搜索 kernel。 +### 风险 1:旧 zero-result fallback 残留,导致双入口并存 -## 风险与缓解 +- 缓解:直接用测试断言普通搜索路径不再 fetch fuzzy endpoint。 + +### 风险 2:助手 host 仍挂在会被详情替换的 DOM 内 + +- 缓解:host 固定放在 app shell/body 级;把 `Projects` 页缩回承接面而不是生命周期控制器。 + +### 风险 3:拖动实现把助手重新做成第二套浮层系统 + +- 缓解:复用同一个 host,只记录 `left/top`,不引入 portal/overlay manager/第三方库。 + +### 风险 4:结果态恢复和会话恢复互相污染 + +- 缓解:`active_main_result` 与 placement 独立存储;只有 `scope_key` 匹配才恢复旧结果态。 + +### 风险 5:实现者把 V1 多轮追问升级成隐式复杂会话系统 + +- 缓解:V1 只允许客户端 `composed_query`;需要更强语义时另开 V2 conversation summarizer 设计。 + +## 回滚策略 -| 风险 | 影响 | 缓解 | -| --- | --- | --- | -| 主搜索框旧 fuzzy 触发残留 | 两个自然语言入口并存 | 测试直接断言普通无结果不 fetch endpoint | -| 助手 host 挂载位置错误 | 打开详情时助手消失 | host 挂在 app shell/body 级,不挂 `.detail-column` | -| `返回普通项目库` 只做 UI 切换 | 刷新后旧 AI 结果复活 | 清除 `active_main_result` 并测试刷新恢复 | -| project_id 映射缺失 | 面板展示假链接 | 映射不到就跳过,不补造项目 | -| 多轮组合噪声 | 后续查询偏离用户意图 | V1 只做简单 query composition,必要时进入 V2 summarizer | +1. 如果 Phase 0 失败: + - 保留主搜索框关键词过滤和普通空状态。 + - 不恢复旧的隐式 fuzzy fallback。 +2. 如果 Phase 1 / 2 失败: + - 隐藏全局助手 host。 + - 保留服务端 fuzzy endpoint 与普通项目库能力。 +3. 如果 Phase 3 失败: + - 暂时禁用 `查看全部结果` 或结果链接分流。 + - 保留侧边助手基础搜索预览,不伪装主区联动已完成。 +4. 如果 Phase 4 失败: + - 允许临时回退为固定右侧抽屉。 + - 不保留半成品拖动或错误恢复逻辑。 +5. 任意阶段都不得通过修改 fuzzy endpoint contract、扩 URL 状态或引入长期记忆来“绕过当前问题”。 ## 结论记录 -本计划采用最小可落地路径:保留已验证的 fuzzy endpoint 和搜索服务,只移动前端触发入口并新增侧边助手状态层。实现阶段不应新增后端搜索能力,也不应重做主项目页面。 +- 本计划保护的不是“再做一个搜索 UI”,而是设计冻结的一句话产品心智:全局入口、轻量预览、结果回到 `Projects` 承接。 +- 本计划把最容易在实现时偷换语义的部分前置写死了:旧入口剥离、route-agnostic host、样式层所有权、最多 3 条预览、`more` 去重、scope 恢复、拖动边界、URL 不落会话。 +- 本计划选择最小可落地路径:复用既有 fuzzy endpoint、项目卡映射和 detail dock,不新增后端会话系统、不重做主项目页。 ## 下一阶段入口 -进入 ExecPlan review;通过后再进入代码实施。 +1. 先跑 `exec-plan:preflight`,确认文档结构与索引规则通过。 +2. 然后进入 exec-plan review;若评审通过,再按 `Phase 0 -> Phase 5` 顺序实施。 +3. 若后续需要更强的多轮语义、长期记忆或更复杂的移动端交互,必须单开 follow-up 设计与 exec-plan,不在本计划里偷扩 scope。 diff --git a/package.json b/package.json index f26fafb..7930772 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "build-kb": "tsx src/cli.ts build-kb", "record-agent-task": "tsx src/cli.ts record-agent-task", "visual-console": "tsx src/cli.ts visual-console", - "visual-console:web": "tsx app/server.ts", + "visual-console:web": "tsx app/server.ts", + "visual-console:codex": "set HOST=127.0.0.1&& set PORT=3211&& tsx app/server.ts", "start:prod:web": "node dist/app/server.js", "typecheck": "tsc --noEmit", "test": "vitest run src/__tests__", diff --git a/src/__tests__/visualConsoleFuzzySearch.test.ts b/src/__tests__/visualConsoleFuzzySearch.test.ts index 5e67804..11939d7 100644 --- a/src/__tests__/visualConsoleFuzzySearch.test.ts +++ b/src/__tests__/visualConsoleFuzzySearch.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; import { + clampSideAssistantPlacement, canRestoreSideAssistantMainResult, composeSideAssistantQuery, createEmptySideAssistantSession, + normalizeSideAssistantPlacement, pickSideAssistantPreviewIds, renderClientScriptSource, resetSideAssistantConversation, + shouldStartSideAssistantDrag, } from "../../app/visualConsole/clientScript.ts"; import { renderPrimary } from "../../app/visualConsole/ossPages.ts"; import { buildOverviewView, buildProjectsView } from "../visualConsole/build.ts"; @@ -21,6 +24,7 @@ describe("visual console fuzzy project search", () => { expect(html).toContain('data-projects-ai-assistant-host="true"'); expect(html).toContain('data-projects-ai-launch="true"'); + expect(html).toContain('draggable="false"'); expect(html).toContain('data-projects-ai-panel="true"'); expect(html).toContain('data-projects-ai-input="true"'); expect(html).toContain('data-projects-ai-main-result="true"'); @@ -47,6 +51,16 @@ describe("visual console fuzzy project search", () => { expect(source).toContain("/api/projects/fuzzy-search"); expect(source).toContain("const bindGlobalSideAssistant = () =>"); expect(source).toContain("const submitAssistantQuery = async (rawQuery) =>"); + expect(source).toContain("visual-console-side-assistant-placement-v4"); + expect(source).toContain('panelHandle instanceof HTMLElement && panelHandle.addEventListener("dblclick", restoreDefaultPlacement)'); + expect(source).toContain('window.addEventListener("resize", () =>'); + expect(source).toContain('launcherHandle.addEventListener("dragstart"'); + expect(source).toContain("requestAnimationFrame(renderDragFrame)"); + expect(source).toContain("translate3d("); + expect(source).not.toContain("assistantHost.style.width"); + expect(source).not.toContain("onLauncherTap"); + expect(source.match(/clearSideAssistantPlacement\(\);/g)).toHaveLength(1); + expect(source).toContain("renderAssistantPanel();\n applySideAssistantPlacement(assistantHost);"); expect(source).toContain("assistantForm instanceof HTMLFormElement"); expect(source).toContain("composeSideAssistantQuery(assistantSession.search_context, trimmed)"); expect(source).not.toContain("shouldUseDirectProjectSearch(cards, state.search, directCards) === false"); @@ -79,6 +93,27 @@ describe("visual console fuzzy project search", () => { expect(canRestoreSideAssistantMainResult("", "new")).toBe(false); }); + it("normalizes and clamps side assistant placement inside the viewport", () => { + expect(normalizeSideAssistantPlacement({ left: 12, top: 24 })).toEqual({ left: 12, top: 24 }); + expect(normalizeSideAssistantPlacement({ left: "12", top: 24 })).toEqual({ left: 12, top: 24 }); + expect(normalizeSideAssistantPlacement({ left: "x", top: 24 })).toBeNull(); + + expect( + clampSideAssistantPlacement( + { left: -40, top: 800 }, + { width: 400, height: 300 }, + { width: 120, height: 80 }, + ), + ).toEqual({ left: 8, top: 212 }); + }); + + it("starts dragging only after movement clears the threshold", () => { + expect(shouldStartSideAssistantDrag({ x: 1, y: 1 })).toBe(false); + expect(shouldStartSideAssistantDrag({ x: 2, y: 0 })).toBe(false); + expect(shouldStartSideAssistantDrag({ x: 4, y: 0 })).toBe(true); + expect(shouldStartSideAssistantDrag({ x: 0, y: -5 })).toBe(true); + }); + it("resets new chat without forcing the panel closed", () => { const cleared = resetSideAssistantConversation( {