diff --git a/docs/design/P0_UI_Spec.md b/docs/design/P0_UI_Spec.md index 989db19..9ab7b93 100644 --- a/docs/design/P0_UI_Spec.md +++ b/docs/design/P0_UI_Spec.md @@ -33,7 +33,7 @@ | 控件间距 | 14 pt | 18 px | | 圆角 | 20 pt,暂沿用 | 20 px | -栏左右边缘与实际 Finder 窗口对齐。弹出斜杠菜单、问题或结果时,窗口向上扩展,输入栏底边不漂移。普通窗口空间不足时先上移 Finder,必要时缩短;仅恢复未被用户再次修改的布局。全屏采用窗口内侧底边。 +底栏是绝对定位的:底边永远钉在屏幕底部(Dock 之上),不随访达窗口的高度、位置或是否贴底而改变;横向范围与访达窗口左右对齐,超出屏幕时收进可见区域。以底栏为核心:斜杠菜单、问题与结果气泡一律向上展开,面板只向上长高,底栏在屏幕上的位置唯一确定。Finder 窗口本身从不被移动或缩放。收起动画向屏幕下方滑出整条底栏。全屏同样贴屏幕底部。 拖动或缩放期间隐藏,鼠标释放且位置稳定 140 ms 后显示。仅识别 Finder 文件浏览窗口;空格打开的 Quick Look 不替换关联窗口。关闭、最小化或切换其他应用隐藏栏,任务继续独立运行。 @@ -41,7 +41,7 @@ 磨砂为默认值。macOS 使用 `NSVisualEffectView`,macOS 26+ 可选 `NSGlassEffectView`;减少透明度时降级。浏览器仅提供 CSS 磨砂预览。原生外观状态以 SQLite 为准,跨窗口事件同步;旧 localStorage 偏好只导入一次。 -`+` 选择文件,纸飞机提交,齿轮打开全部设置。输入 `/` 列出 `/model`、`/thinking`、`/speed`、`/settings`;方向键选择,Enter 确认,Esc 收起。模型和参数只影响新任务。未连接或服务未声明的能力不伪造可用选项。 +选中即上下文:访达选区自动跟随,不在底栏上方重复展示文件名。`+` 按钮用于手动选文件;有文件时按钮右上角显示数量角标,手动选择状态下 `+` 变为 `×`,点击清除并回到跟随访达选区。纸飞机提交,齿轮打开全部设置。输入 `/` 列出 `/model`、`/thinking`、`/speed`、`/settings`;方向键选择,Enter 确认,Esc 收起。模型和参数只影响新任务。未连接或服务未声明的能力不伪造可用选项。 快捷键通过实际按键录入,录入时临时停用已有全局快捷键,结束或失焦后恢复。中文输入法确认不触发提交,发送与连接操作防止连点,并显示加载、失败与保存结果。 diff --git a/docs/previews/control-bar-dark.png b/docs/previews/control-bar-dark.png index fe2fe8b..239f621 100644 Binary files a/docs/previews/control-bar-dark.png and b/docs/previews/control-bar-dark.png differ diff --git a/docs/previews/control-bar-light.png b/docs/previews/control-bar-light.png index 6fa7a75..3d3bdfc 100644 Binary files a/docs/previews/control-bar-light.png and b/docs/previews/control-bar-light.png differ diff --git a/docs/previews/settings-dark.png b/docs/previews/settings-dark.png index 1b7d55b..1e73c64 100644 Binary files a/docs/previews/settings-dark.png and b/docs/previews/settings-dark.png differ diff --git a/docs/previews/settings-general.png b/docs/previews/settings-general.png index 5133dfe..ffcec89 100644 Binary files a/docs/previews/settings-general.png and b/docs/previews/settings-general.png differ diff --git a/docs/qa/P0_Acceptance.md b/docs/qa/P0_Acceptance.md index 26bd09a..17a078c 100644 --- a/docs/qa/P0_Acceptance.md +++ b/docs/qa/P0_Acceptance.md @@ -154,3 +154,20 @@ GitHub 登录新增令牌方式:OAuth 应用尚未注册时,界面提供「 - `pnpm build` 通过;`pnpm test` Playwright 21 项通过(新增文件浮层可见性与运行态两项)。 - `swift test` 5 项通过;两份 `cargo clippy -D warnings` 通过。 + +## 底栏绝对定位与选区交互(本轮) + +上一轮把“文件选中提示”做成可见浮层,方向做反了:选中就是选中,用户不需要 +在底栏上方再看一遍文件名。本轮改为——访达选区零展示、自动跟随;`+` 按钮带 +数量角标,手动选文件时 `+` 变 `×`,点击清除并回到跟随访达。文件浮层只剩瞬时 +提示(错误/结果)使用。 + +底栏定位从“贴在访达窗口下沿”改为绝对规则:底边永远钉在屏幕底部(Dock 之上), +横向与访达窗口对齐、超屏收进可见区域;气泡一律向上展开,面板只向上长高。 +此前访达窗口较高时底栏被顶到屏幕中部、看起来“到处乱跑”的问题从根上消除。 +收起动画相应改为向屏幕下方滑出(`tuckDistance` 取负)。 + +- `swift test` 6 项通过(屏幕底部锚定、窗口高度无关、气泡向上不动栏、超屏收边、全屏同规则、拖动回落)。 +- `pnpm build` 通过;`pnpm test` Playwright 21 项通过(角标/变叉清除、不再渲染文件列表)。 +- 真机复验:解锁后唤起底栏,确认贴屏幕底部、访达窗口任意移动/缩放底栏不动、 + 选文件只有角标无上方提示。 diff --git a/src-tauri/native/Sources/FleqiGlass/AttachmentGeometry.swift b/src-tauri/native/Sources/FleqiGlass/AttachmentGeometry.swift index 2f45875..3388b71 100644 --- a/src-tauri/native/Sources/FleqiGlass/AttachmentGeometry.swift +++ b/src-tauri/native/Sources/FleqiGlass/AttachmentGeometry.swift @@ -1,13 +1,13 @@ import Foundation -/// 底栏的定位规则:底栏永远贴在 Finder 窗口下边缘下方,与窗口左右对齐。 +/// 底栏的定位规则:底栏是绝对定位的——底边永远钉在屏幕底部(Dock 之上), +/// 不随访达窗口的高度、位置或是否贴底而改变;横向范围与访达窗口对齐。 /// 面板只改变高度,底栏本体的位置由 `barOffset`(底栏顶边到面板顶边的距离)决定, -/// 因此气泡向上或向下展开都不会让底栏移动。这里从不修改 Finder 窗口的框架。 +/// 气泡一律向上展开。这里从不修改 Finder 窗口的框架。 struct AttachmentGeometry { - static let gap: CGFloat = 8 struct Placement { var panel: CGRect - /// 底栏下方到屏幕可见区域底部的空间,气泡向下展开时可用。 + /// 底栏下方没有可用空间:气泡永远向上展开,保留字段供界面判断。 var spaceBelow: CGFloat /// 底栏上方到屏幕可见区域顶部的空间,气泡向上展开时可用。 var spaceAbove: CGFloat @@ -22,20 +22,13 @@ struct AttachmentGeometry { ) -> Placement? { guard host.width > 200, host.height > 100 else { return nil } let height = max(barHeight, contentHeight) - // 全屏窗口没有窗口外的空间:把底栏放在窗口内侧底部,气泡只能向上展开。 - let barTop = fullscreen ? host.minY + gap + barHeight : host.minY - gap - let spaceBelow = max(0, barTop - barHeight - visible.minY) + let width = min(host.width, visible.width) + let x = min(max(host.minX, visible.minX), visible.maxX - width) + // AppKit 中 y 轴向上:面板底边贴屏幕可见区域底部,顶边随内容增高。 + let panel = CGRect(x: x, y: visible.minY, width: width, height: height) + let barTop = panel.maxY - barOffset let spaceAbove = max(0, visible.maxY - barTop) - // barOffset 是面板顶边到底栏顶边的距离:面板只向上(气泡在上)或向下 - // (气泡在下)长高,底栏本身不动。AppKit 中面板顶边是 maxY。 - var topEdge = barTop + barOffset - let lowest = visible.minY + height - topEdge = min(max(topEdge, lowest), visible.maxY) - return Placement( - panel: CGRect(x: host.minX, y: topEdge - height, width: host.width, height: height), - spaceBelow: spaceBelow, - spaceAbove: spaceAbove - ) + return Placement(panel: panel, spaceBelow: 0, spaceAbove: spaceAbove) } } diff --git a/src-tauri/native/Sources/FleqiGlass/HostBridge.swift b/src-tauri/native/Sources/FleqiGlass/HostBridge.swift index 5160b5a..36e3f2b 100644 --- a/src-tauri/native/Sources/FleqiGlass/HostBridge.swift +++ b/src-tauri/native/Sources/FleqiGlass/HostBridge.swift @@ -477,8 +477,9 @@ public func fleqiHostCommand(_ pointer: UnsafePointer?) -> UnsafeMutableP if let value=request["barOffset"] as? Double,value.isFinite {host.barOffset=CGFloat(min(1600,max(0,value)))} if let panel=host.panel { let motion=NativeWindowMotion.forWindow(panel) - // 收起时只需上移“底栏顶边偏移 + 底栏高度”,气泡不会露在窗口外。 - motion.tuckDistance = host.barOffset + host.barHeight + 2 + // 收起时向屏幕下方滑出“面板高度 - 底栏顶边偏移 + 2”,把底栏整条推出屏幕底边; + // 面板底边本就贴屏幕底部,气泡在上方不会露出来。 + motion.tuckDistance = -(host.height - host.barOffset) - 2 } host.layout() return jsonResult(["ok":true,"spaceBelow":Double(host.spaceBelow),"spaceAbove":Double(host.spaceAbove)]) diff --git a/src-tauri/native/Sources/FleqiGlass/WindowMotion.swift b/src-tauri/native/Sources/FleqiGlass/WindowMotion.swift index 00e05f6..a1af1cd 100644 --- a/src-tauri/native/Sources/FleqiGlass/WindowMotion.swift +++ b/src-tauri/native/Sources/FleqiGlass/WindowMotion.swift @@ -80,9 +80,9 @@ final class NativeWindowMotion { let target=fullFrame ?? window.frame let anchor=origin ?? CGPoint(x:target.midX,y:target.maxY) let contracted=compact ? target.offsetBy(dx:0,dy:8):FleqiMotion.contracted(target,around:anchor) - // 仅底栏(attachment)使用“从宿主窗口后方滑入/滑出 + 高斯模糊”: - // 收起距离按底栏自身高度计算,气泡在上或在下都不会露出多余部分。 - let tuck=attachment ? target.offsetBy(dx:0,dy:tuckDistance>0 ? tuckDistance : target.height):contracted + // 仅底栏(attachment)使用“滑入/滑出 + 高斯模糊”:底栏钉在屏幕底部, + // 收起距离为负(向屏幕下方滑出),出现时从底边下方滑回原位。 + let tuck=attachment ? target.offsetBy(dx:0,dy:tuckDistance != 0 ? tuckDistance : target.height):contracted let startFrame=tuck let time=FleqiMotion.reduced ? 0:(compact ? FleqiMotion.feedbackDuration:FleqiMotion.windowDuration) if visible && !window.isVisible { diff --git a/src-tauri/native/Tests/FleqiGlassTests/AttachmentTests.swift b/src-tauri/native/Tests/FleqiGlassTests/AttachmentTests.swift index 79c82ae..a319a83 100644 --- a/src-tauri/native/Tests/FleqiGlassTests/AttachmentTests.swift +++ b/src-tauri/native/Tests/FleqiGlassTests/AttachmentTests.swift @@ -4,49 +4,55 @@ import XCTest final class AttachmentTests: XCTestCase { let screen = CGRect(x: 0, y: 24, width: 1440, height: 850) - func testBarStaysAlignedUnderTheWindowAndNeverMovesTheWindow() throws { - let host = CGRect(x: 140, y: 400, width: 1000, height: 600) - let bar = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) - XCTAssertEqual(bar.panel.minX, host.minX) - XCTAssertEqual(bar.panel.width, host.width) - // 底栏顶边在窗口下边缘下方 8pt,且面板整体位于窗口之外。 - XCTAssertEqual(host.minY - bar.panel.maxY, 8) - // 气泡向下展开:面板向屏幕下方长高,顶边(底栏位置)不动。 - let popup = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 320, barOffset: 0, fullscreen: false)) - XCTAssertEqual(popup.panel.maxY, bar.panel.maxY) - XCTAssertLessThan(popup.panel.minY, bar.panel.minY) + func testBarIsPinnedToTheScreenBottomRegardlessOfWindow() throws { + // 窗口高、窗口矮、窗口贴底:底栏面板必须完全一致地钉在屏幕底部。 + let tall = CGRect(x: 140, y: 40, width: 1000, height: 800) + let short = CGRect(x: 140, y: 700, width: 1000, height: 200) + let tight = CGRect(x: 140, y: 24, width: 1000, height: 120) + for host in [tall, short, tight] { + let placement = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) + XCTAssertEqual(placement.panel.minY, screen.minY, "面板底边必须贴屏幕可见区域底部") + XCTAssertEqual(placement.panel.maxY, screen.minY + 56, "底栏顶边位置必须唯一确定") + XCTAssertEqual(placement.panel.minX, host.minX) + XCTAssertEqual(placement.panel.width, host.width) + XCTAssertEqual(placement.spaceBelow, 0) + } + // Finder 窗口本身从不被移动或缩放:以上只读几何,不产生任何窗口操作。 } func testBubbleAboveKeepsTheBarInPlace() throws { let host = CGRect(x: 0, y: 120, width: 1000, height: 600) let bar = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) - // 气泡向上展开:面板向上长高,底栏顶边仍然固定在窗口下方 8pt。 + // 气泡向上展开:面板向上长高,底栏顶边仍然钉在同一个位置。 let above = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 320, barOffset: 264, fullscreen: false)) - XCTAssertEqual(above.panel.maxY, bar.panel.maxY + 264) XCTAssertEqual(above.panel.minY, bar.panel.minY) - XCTAssertEqual(above.panel.minY + 56, bar.panel.minY + 56) + // barOffset 是面板顶边到底栏顶边的距离:320 - 264 = 56,与无气泡时一致。 + XCTAssertEqual(above.panel.maxY - 264, bar.panel.maxY, accuracy: 0.001) } func testSpaceReportingForBubbleDirection() throws { - let roomy = CGRect(x: 0, y: 700, width: 1000, height: 400) - let roomyPlacement = try XCTUnwrap(AttachmentGeometry.layout(host: roomy, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) - XCTAssertGreaterThan(roomyPlacement.spaceBelow, 200) - XCTAssertGreaterThan(roomyPlacement.spaceAbove, 150) - // 窗口贴着屏幕底部时,下方没有空间,气泡必须向上。 - let tight = CGRect(x: 0, y: 24, width: 1000, height: 400) - let tightPlacement = try XCTUnwrap(AttachmentGeometry.layout(host: tight, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) - XCTAssertEqual(tightPlacement.spaceBelow, 0) - XCTAssertGreaterThan(tightPlacement.spaceAbove, 200) - // 面板始终留在屏幕可见区域内,Finder 窗口本身不被改动。 - XCTAssertGreaterThanOrEqual(tightPlacement.panel.minY, screen.minY) - XCTAssertLessThanOrEqual(tightPlacement.panel.maxY, screen.maxY) + let host = CGRect(x: 0, y: 700, width: 1000, height: 400) + let placement = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) + // 底栏下方永远没有空间(贴着屏幕底),气泡只能向上。 + XCTAssertEqual(placement.spaceBelow, 0) + XCTAssertEqual(placement.spaceAbove, screen.maxY - (screen.minY + 56), accuracy: 0.001) + XCTAssertGreaterThan(placement.spaceAbove, 700) + } + + func testPanelClampedToScreenWidth() throws { + // 窗口比屏幕还宽、或偏出屏幕左右边缘时,面板收进可见区域。 + let wide = CGRect(x: -200, y: 400, width: 3000, height: 600) + let placement = try XCTUnwrap(AttachmentGeometry.layout(host: wide, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: false)) + XCTAssertGreaterThanOrEqual(placement.panel.minX, screen.minX) + XCTAssertLessThanOrEqual(placement.panel.maxX, screen.maxX) + XCTAssertEqual(placement.panel.width, screen.width) } - func testFullscreenKeepsTheBarInsideTheWindow() throws { + func testFullscreenUsesTheSameScreenBottomAnchor() throws { let host = CGRect(x: 0, y: 24, width: 1440, height: 850) let placement = try XCTUnwrap(AttachmentGeometry.layout(host: host, visible: screen, barHeight: 56, contentHeight: 56, barOffset: 0, fullscreen: true)) - XCTAssertEqual(placement.panel.minY, host.minY + 8) - XCTAssertLessThan(placement.spaceBelow, 20) + XCTAssertEqual(placement.panel.minY, screen.minY) + XCTAssertEqual(placement.spaceBelow, 0) } func testDragStaysHiddenUntilReleaseAndSettle() { diff --git a/src/AgentBar.tsx b/src/AgentBar.tsx index 38c8cfa..4105756 100644 --- a/src/AgentBar.tsx +++ b/src/AgentBar.tsx @@ -1,6 +1,6 @@ import { Reveal } from './motion'; import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { ArrowUpRight, File, LoaderCircle, X } from 'lucide-react'; +import { ArrowUpRight, LoaderCircle, X } from 'lucide-react'; import { BorderBeam } from 'border-beam'; import { AssetIcon,SettingsIcon,SvgImage } from './icons'; import { SlashMenu,slashOptions,type SlashOption } from './SlashMenu'; @@ -10,7 +10,7 @@ import type { Appearance } from './appearance'; import { getMaterialSupport, nativeHost, updateNativeMaterial, type MaterialSupport } from './native'; import { AppearancePanel } from './AppearancePanel'; -interface Props { appearance: Appearance; onAppearance: (value: Partial) => void; scene: boolean; layoutKey?:string; onMenuOpenChange?:(open:boolean)=>void; controller?:FleqiController; live?: {busy:boolean;files?:string[];onClearFiles?:()=>Promise;onSubmit:(text:string)=>Promise;onChoose:()=>Promise;onSettings:()=>Promise;onHide:()=>Promise;answerMode?:boolean;onSubmitAnswer?:(text:string)=>Promise} } +interface Props { appearance: Appearance; onAppearance: (value: Partial) => void; scene: boolean; layoutKey?:string; onMenuOpenChange?:(open:boolean)=>void; controller?:FleqiController; live?: {busy:boolean;files?:string[];explicit?:boolean;onClearFiles?:()=>Promise;onSubmit:(text:string)=>Promise;onChoose:()=>Promise;onSettings:()=>Promise;onHide:()=>Promise;answerMode?:boolean;onSubmitAnswer?:(text:string)=>Promise} } export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, controller,onMenuOpenChange }: Props) { const bar = useRef(null); @@ -101,9 +101,11 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con }; const closeSettings = () => { setOpen(false); }; - // 实时底栏展示 Finder 实际捕获的选区,用户提交前就能看到会处理哪些文件。 + // 选中就是选中:访达选区不再在底栏上方重复展示。只有手动通过 `+` 选文件时, + // `+` 变成 ×(点击清除、回到跟随访达),并在按钮角标上显示数量。 const liveFiles=live?.files??[]; const shownFiles=live?liveFiles:files; + const explicit=live?!!live.explicit:files.length>0; // 任务进行中:快捷按钮收起、输入框收成圆圈转圈,空出的横条按顺序掠过工具调用。 const running=!!live?.busy&&!live?.answerMode; const activeTask=controller?.snapshot?.tasks.find(t=>t.id===controller.snapshot?.activeTaskId)??null; @@ -120,12 +122,8 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con return (
- 0||!!notice} edge="bottom" className="context-reveal"> +
- {shownFiles.length > 0 &&
- {shownFiles.length === 1 ? shownFiles[0].split('/').at(-1) : `已选择 ${shownFiles.length} 个文件:${shownFiles.slice(0,3).map(p=>p.split('/').at(-1)).join('、')}${shownFiles.length>3?'…':''}`} - -
} {notice &&
{notice}
} @@ -134,8 +132,13 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con void selectCommand(o)} onHover={setMenuIndex} onSettings={()=>controller?void controller.openSettings('models'):setOpen(true)}/>
- + {running&&} { setFiles(Array.from(event.target.files ?? [], (file) => file.name)); event.target.value = ''; input.current?.focus(); }} /> diff --git a/src/LiveAgentBar.tsx b/src/LiveAgentBar.tsx index 1d970bd..caeac25 100644 --- a/src/LiveAgentBar.tsx +++ b/src/LiveAgentBar.tsx @@ -26,15 +26,15 @@ export function LiveAgentBar({controller:c}:{controller:FleqiController}){ const reply=task&&terminalStates.has(task.status)?{status:task.status,message:task.message||'任务已完成',label:statusLabels[task.status]??task.status}:null; const contextRef=useRef(data?.context??null); useEffect(()=>{contextRef.current=data?.context??null;},[data?.context]); - // 气泡方向:底栏固定在 Finder 窗口下方不动,气泡按屏幕空间向下或向上展开。 - const [direction,setDirection]=useState<'below'|'above'>('below'); + // 气泡方向:底栏绝对钉在屏幕底部,气泡一律向上展开。 + const [direction,setDirection]=useState<'below'|'above'>('above'); const spaces=useRef({below:0,above:0}); const contentRef=useRef(0); const measure=useRef<()=>void>(()=>{}); - // 气泡、所选文件提示、斜杠菜单都是绝对定位的浮层:它们从不参与底栏的排版, - // 底栏在网页里的位置只由下面这段预留空间决定,因此屏幕上永远不动。 + // 气泡、提示、斜杠菜单都不参与底栏排版:底栏在网页里的位置只由下面这段 + // 预留空间决定,面板向上长高,底栏在屏幕上永远不动。 const overlaySelector='.agent-bubble, .context-popover, .slash-menu, .interaction-card'; - const room={top:6,bottom:10}; + const room={top:6,bottom:0}; measure.current=()=>{ if(!nativeHost||!region.current)return; const element=region.current; @@ -111,7 +111,6 @@ export function LiveAgentBar({controller:c}:{controller:FleqiController}){ const submit=async(text:string)=>{const context=data.context?.hostWindow==='explicit-selection'?data.context.id:null;if(!request.current||request.current.text!==text||request.current.context!==context)request.current={text,context,id:crypto.randomUUID()};const task=await backend('task_submit',{input:{requestId:request.current.id,prompt:text,contextId:context}});request.current=null;await c.refresh();return task;}; const submitAnswer=async(text:string)=>{if(!pending)return;await backend('interaction_answer',{id:pending.id,answer:text});await c.refresh();}; const notice=c.notice??c.error; - const shownContextFiles=(data.context?.files??[]).length; return
{direction==='above'&&
@@ -119,7 +118,7 @@ export function LiveAgentBar({controller:c}:{controller:FleqiController}){
{reply?.label}

{reply?.message}

{notice}
} - void c.save({appearance})} scene={false} live={{busy,onSubmit:submit,onChoose:()=>c.run('choose_files'),onSettings:()=>c.openSettings('general'),onHide:()=>c.run('bar_toggle',{show:false}),answerMode,onSubmitAnswer:submitAnswer,files:data.context?.files??[],onClearFiles:()=>c.run('context_clear')}}/> + void c.save({appearance})} scene={false} live={{busy,onSubmit:submit,onChoose:()=>c.run('choose_files'),onSettings:()=>c.openSettings('general'),onHide:()=>c.run('bar_toggle',{show:false}),answerMode,onSubmitAnswer:submitAnswer,files:data.context?.files??[],explicit:data.context?.hostWindow==='explicit-selection',onClearFiles:()=>c.run('context_clear')}}/> {direction==='below'&&
{pending&&}
{reply?.label}

{reply?.message}

diff --git a/src/Preview.tsx b/src/Preview.tsx index fb54074..be90168 100644 --- a/src/Preview.tsx +++ b/src/Preview.tsx @@ -11,7 +11,7 @@ document.documentElement.dataset.surface = standalone ? 'bar' : 'scene'; // 演示开关:`?surface=preview&demo=running` 让场景里的控制栏停在“任务进行中” // 形态(辅助按钮收拢、输入框缩成圆圈转圈、左侧掠过工具条),供界面验收与自动化测试使用。 const demoLive = params.get('demo') === 'running' - ? { busy: true, files: ['/tmp/示例图片.png', '/tmp/旅行合同.pdf'], onSubmit: async () => undefined, onChoose: async () => undefined, onSettings: async () => undefined, onHide: async () => undefined, onClearFiles: async () => undefined } + ? { busy: true, files: ['/tmp/示例图片.png', '/tmp/旅行合同.pdf'], explicit: true, onSubmit: async () => undefined, onChoose: async () => undefined, onSettings: async () => undefined, onHide: async () => undefined, onClearFiles: async () => undefined } : undefined; export function Preview() { diff --git a/src/dashboard.css b/src/dashboard.css index abb36d2..8aac7a6 100644 --- a/src/dashboard.css +++ b/src/dashboard.css @@ -148,7 +148,7 @@ html[data-surface='settings'],html[data-surface='settings'] body,html[data-surfa .interaction-card {padding:16px;border:1px solid var(--border);background:var(--card);color:var(--foreground);border-radius:10px;margin-bottom:12px;} .interaction-card>p {font-size:13px;line-height:1.8;margin:9px 0 15px;}.section-kicker {font-size:11px;color:var(--muted-foreground);} .button-row {display:flex;gap:8px;flex-wrap:wrap;}.answer-form {display:flex;gap:8px;} -.native-agent {background:transparent;margin:0;min-height:100vh;display:flex;flex-direction:column;justify-content:flex-end;padding:14px 0 18px;--bar-height:56px;--button-size:36px;--control-gap:14px;} +.native-agent {background:transparent;margin:0;min-height:100vh;display:flex;flex-direction:column;justify-content:flex-end;padding:14px 0 8px;--bar-height:56px;--button-size:36px;--control-gap:14px;} .live-region {width:100%;color:var(--bar-ink);} .native-agent .bar-controls {padding-inline:20px;}.native-agent .command-input {height:34px;font-size:14px;padding-inline:16px;} .native-agent .round-button svg {width:21px;height:21px;} diff --git a/src/styles.css b/src/styles.css index 7d1fbe9..685f187 100644 --- a/src/styles.css +++ b/src/styles.css @@ -49,6 +49,9 @@ html[data-native-backdrop='true'] .scene-background { display: none; } .round-button:hover:not(:disabled) { background: var(--control-hover); } .round-button:active:not(:disabled) { transform: scale(.94); } .add-button svg { width: 25px; height: 25px; stroke-width: 1.9; } +/* 选中数量只以角标形式出现在 `+` 上;选中即上下文,不再在底栏上方重复展示。 */ +.add-button { position: relative; } +.add-badge { position: absolute; top: -3px; right: -3px; min-width: 17px; height: 17px; padding: 0 4px; border-radius: 9px; background: #2f6fe4; color: #fff; font-size: 10px; font-weight: 600; line-height: 1; display: grid; place-items: center; pointer-events: none; border: 1px solid var(--bar-border); } .send-button svg { width: 20px; height: 20px; stroke-width: 2.25; transform: translate(-1px, 1px); } .send-button:disabled { opacity: .45; } .settings-button svg { width: 23px; height: 23px; stroke-width: 2.2; } @@ -92,11 +95,8 @@ legend { padding: 0; font-size: 11px; color: var(--bar-secondary); margin-bottom .material-option:disabled { opacity: .48; } .panel-footnote, .accessibility-note { color: var(--bar-secondary); font-size: 10px; line-height: 1.6; margin: 0; } .context-popover { position: absolute; z-index: 4; bottom: calc(100% + 10px); left: 26px; right: 26px; width: fit-content; max-width: calc(100% - 52px); border-radius: 12px; padding: 9px 12px; background: var(--panel-fill); color: var(--bar-ink); border: 1px solid var(--bar-border); box-shadow: 0 1px 4px #00000018; backdrop-filter: blur(20px); font-size: 12px; } -.file-context, .notice { display: flex; align-items: center; gap: 8px; } -.file-context > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.notice { max-width: 470px; line-height: 1.6; } +.notice { display: flex; align-items: center; gap: 8px; max-width: 470px; line-height: 1.6; } .notice > svg { flex-shrink: 0; } -.file-context + .notice { margin-top: 5px; } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .standalone { min-height: 100vh; display: flex; flex-direction: column; justify-content: flex-end; padding: 40px 24px 24px; background: transparent; } .standalone .agent-region { width: min(1120px, 100%); margin-inline: auto; } diff --git a/tests/agent-bar.spec.ts b/tests/agent-bar.spec.ts index fd44234..03838a2 100644 --- a/tests/agent-bar.spec.ts +++ b/tests/agent-bar.spec.ts @@ -55,15 +55,22 @@ test('IME confirmation does not submit and preview never claims file execution', await expect(input).toHaveValue('把图片转为 JPG'); }); -test('selecting files updates context without reading or uploading file contents', async ({ page }) => { +test('selected files show only as a badge on the plus button, which becomes a clear control', async ({ page }) => { await page.goto('/?surface=preview'); + await expect(page.getByRole('button', { name: '选择文件' })).toBeVisible(); await page.getByLabel('添加文件').setInputFiles([ { name: '中文 图片.png', mimeType: 'image/png', buffer: Buffer.from('fixture') }, { name: '第二张.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('fixture') }, ]); - await expect(page.getByRole('status')).toContainText('已选择 2 个文件'); - await page.getByRole('button', { name: '清除所选文件' }).click(); + // 选中即上下文:不在底栏上方重复展示,只体现在 `+` 的角标与按钮形态上。 + await expect(page.locator('.add-badge')).toHaveText('2'); + await expect(page.locator('.file-context')).toHaveCount(0); await expect(page.getByRole('status')).toHaveCount(0); + const clear = page.getByRole('button', { name: '清除所选文件' }); + await expect(clear).toBeVisible(); + await clear.click(); + await expect(page.locator('.add-badge')).toHaveCount(0); + await expect(page.getByRole('button', { name: '选择文件' })).toBeVisible(); }); test('compact window preserves equal theme dimensions without horizontal overflow', async ({ page }) => { @@ -97,15 +104,12 @@ test('live bar opens the full settings dialog in browser preview',async({page})= await expect(page.getByRole('heading',{name:'概览',exact:true})).toBeVisible(); }); -test('the selected-file chip stays visible instead of being clipped by the reveal wrapper',async({page})=>{ +test('the bar never renders a file list above itself',async({page})=>{ await page.goto('/?surface=preview'); await page.getByLabel('添加文件').setInputFiles([{name:'图片.png',mimeType:'image/png',buffer:Buffer.from('fixture')}]); - const popover=page.locator('.context-popover'); - await expect(popover).toBeVisible(); - const box=await popover.boundingBox();expect(box&&box.height>20); - // 揭示容器必须放开裁剪,否则绝对定位浮层整体不可见。 - await expect(page.locator('.context-reveal')).toHaveCSS('overflow','visible'); - await expect(page.locator('.context-popover .small-icon')).toBeVisible(); + await expect(page.locator('.file-context')).toHaveCount(0); + await expect(page.locator('.context-popover')).toHaveCount(0); + // 只有瞬时提示(notice)才会使用底栏上方的浮层,且它不再承载文件名。 }); test('running state folds the input into a spinner circle and sweeps tools across the strip',async({page})=>{