Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/design/P0_UI_Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@
`+` 选择文件,纸飞机提交,齿轮打开全部设置。输入 `/` 列出 `/model`、`/thinking`、`/speed`、`/settings`;方向键选择,Enter 确认,Esc 收起。模型和参数只影响新任务。未连接或服务未声明的能力不伪造可用选项。

快捷键通过实际按键录入,录入时临时停用已有全局快捷键,结束或失焦后恢复。中文输入法确认不触发提交,发送与连接操作防止连点,并显示加载、失败与保存结果。

## 任务进行中形态

提交后输入框内的文字清空,`+`、纸飞机与分隔线原地收拢消失(缩向自身中心并淡出),输入框向右侧缩成一个按钮大小的圆圈,圆内显示转圈指示;空出的整条左侧为工具掠影区,按发生顺序以灰色小胶囊从圆圈一侧浮出、向左掠过并在条尾淡出,最多保留最近 5 条,任务尚无动作时显示"正在准备…"呼吸提示。底栏本体与圆圈、掠影条都在面板内部完成布局,屏幕上底栏位置不变。agent 追问(回复模式)不进入该形态。浏览器可用 `?surface=preview&demo=running` 查看该形态。

## 阴影

底栏及其浮层(气泡、文件选择提示、斜杠菜单、外观面板)一律不加外阴影,或只保留模糊半径不超过 8 CSS px(约两毫米)的极小阴影:承载面板紧贴元素边缘,大阴影会被面板裁成半截。内阴影与 1 px 描边不受限。
Binary file modified docs/previews/control-bar-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/previews/control-bar-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/previews/settings-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/previews/settings-general.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions docs/qa/P0_Acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,27 @@ GitHub 登录新增令牌方式:OAuth 应用尚未注册时,界面提供「
更新包、用仓库 Secrets 里的私钥签名、生成 `latest.json`,产物 196 MB 全部正常;发布步骤按预期跳过。
首次正式发版仍需用户决定 tag。修复了两处只在发版路径上出现的问题:打包器要求
`plugins.updater` 配置、`check-package.mjs` 找不到 `--target` 产物目录。

## 底栏交互与视觉收尾(本轮)

针对六项反馈的修复:

1. 输入框位置固定:气泡、文件提示、斜杠菜单全部按浮层测量并在面板内预留空间,
`bar_resize` 带 `barOffset`,Swift 侧只改面板高度、底栏顶边恒贴 Finder 下缘(`swift test` 覆盖)。
2. 选区跟随提速:新增 `selection_peek` 轻量命令(缓存选中容器、不解析目录),
底栏 100 ms 轮询,变化即完整捕获并刷新;分栏视图被顺带选中的父目录在 Swift 侧剔除。
3. 文件选择提示可见:网页预览里浮层被 `motion-reveal/motion-size` 的 `overflow:hidden`
整体裁掉,已在层外放开(原生侧保持裁剪以保留展开动画)。
4. 发送后形态:辅助按钮收拢消失,输入框缩为圆圈转圈(此前 `tool-strip`、
`running-spinner` 与收拢样式完全没有 CSS,因此看不到任何效果)。
5. 工具掠影条:按任务动作顺序以灰色胶囊从圆圈一侧浮出、向左掠过,最多保留 5 条。
6. 阴影统一:底栏、气泡、文件提示、斜杠菜单、外观面板、预览组合图的外阴影全部
移除或压到模糊半径 ≤8 px(约两毫米),不再被紧贴面板裁切。

浏览器可用 `?surface=preview&demo=running` 查看运行态。真机复验项:选区跟随的
即时感、圆圈与掠影条在原生材质下的对比度、阴影收窄后底栏与桌面的层次感。

### 本轮自动化验证

- `pnpm build` 通过;`pnpm test` Playwright 21 项通过(新增文件浮层可见性与运行态两项)。
- `swift test` 5 项通过;两份 `cargo clippy -D warnings` 通过。
51 changes: 44 additions & 7 deletions src-tauri/native/Sources/FleqiGlass/HostBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ private final class FinderHost {
var lastFrame: NSRect?
var automatic = false
var workspaceToken: NSObjectProtocol?
/// 上一次命中选中项的那个容器(outline/table/list)。Finder 的选区挂在很深的
/// 子视图上,每次都走整棵树太贵(最多 2500 次 AX 调用)。轮询时先问缓存下来的
/// 容器,命中就只要一两次属性读取,用来做 200ms 级的选区跟随。
var selectionAnchor: AXUIElement?

init() {
workspaceToken = NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { _ in
Expand Down Expand Up @@ -254,7 +258,9 @@ private final class FinderHost {
visited += 1
let role = attribute(element, kAXRoleAttribute) as? String
if role == kAXOutlineRole || role == kAXTableRole || role == kAXListRole {
for row in attribute(element, kAXSelectedRowsAttribute) as? [AXUIElement] ?? [] where !known(row) {
let selected = attribute(element, kAXSelectedRowsAttribute) as? [AXUIElement] ?? []
if !selected.isEmpty { selectionAnchor = element }
for row in selected where !known(row) {
rows.append(row)
}
} else if role == kAXRowRole || role == kAXImageRole || role == kAXCellRole,
Expand All @@ -266,6 +272,41 @@ private final class FinderHost {
walk(window, 12)
return rows
}
/// 候选选中项:先用缓存容器做一两次 AX 读取,未命中才退回整棵树。
func candidateRows(_ window: AXUIElement) -> [AXUIElement] {
if let anchor = selectionAnchor,
(attribute(anchor, kAXRoleAttribute) as? String).map({ $0 == kAXOutlineRole || $0 == kAXTableRole || $0 == kAXListRole }) == true,
let selected = attribute(anchor, kAXSelectedRowsAttribute) as? [AXUIElement], !selected.isEmpty {
return selected
}
return selectedRows(window)
}
/// 选中路径:按行取地址、去重、丢掉“列视图里被顺带选中的父目录”。
/// 在分栏视图里选中文件时,上一层分栏的当前目录也会处于选中态,
/// 不清理就会把整个文件夹一起当成待处理对象。
func selectionPaths(_ window: AXUIElement) -> [String] {
var files: [String] = []
for row in candidateRows(window) {
guard let path = rowURL(row)?.path, FileManager.default.fileExists(atPath: path), !files.contains(path) else { continue }
files.append(path)
}
if files.isEmpty { files = scriptSelection() }
let directories = Set(files.filter { path in
var isDirectory: ObjCBool = false
return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue
})
guard !directories.isEmpty else { return files }
return files.filter { path in
guard directories.contains(path) else { return true }
return !files.contains { other in other != path && (other as NSString).deletingLastPathComponent == path }
}
}
/// 轻量选区读取:不含目录解析与窗口校验,供底栏高频轮询使用。
func selection() -> [String: Any] {
guard AXIsProcessTrusted() else { return ["error": "请先授予辅助功能权限"] }
guard let window = window() else { return ["error": "没有可关联的 Finder 窗口"] }
return ["files": selectionPaths(window)]
}
/// 目录来源:优先选中文件所在目录,其次窗口内任意项目的所在目录,
/// 最后才回退到 AppleScript(因此 Finder 自动化权限是可选的)。
func windowDirectory(_ window: AXUIElement, files: [String]) -> String? {
Expand Down Expand Up @@ -309,12 +350,7 @@ private final class FinderHost {
func context() -> [String: Any] {
guard AXIsProcessTrusted() else { return ["error": "请先授予辅助功能权限"] }
guard let before = window(), let beforeFrame = frame(before) else { return ["error": "没有可关联的 Finder 窗口,请先打开 Finder"] }
var files: [String] = []
for row in selectedRows(before) {
guard let path = rowURL(row)?.path, FileManager.default.fileExists(atPath: path), !files.contains(path) else { continue }
files.append(path)
}
if files.isEmpty { files = scriptSelection() }
let files = selectionPaths(before)
guard let directory = windowDirectory(before, files: files) else { return ["error": "无法读取 Finder 当前目录,请先在窗口中选中文件或授予 Finder 自动化权限"] }
guard let after = window(), CFEqual(before, after), let afterFrame = frame(after), close(beforeFrame, afterFrame) else { return ["error": "Finder 窗口或选区在捕获期间发生变化,请重新提交"] }
let windowID = attribute(before, kAXIdentifierAttribute) as? String ?? String(CFHash(before))
Expand Down Expand Up @@ -422,6 +458,7 @@ public func fleqiHostCommand(_ pointer: UnsafePointer<CChar>?) -> UnsafeMutableP
case "motion_config": FleqiMotion.configure(request["config"] as? [String:Any] ?? [:]);return jsonResult(["ok":true])
case "permissions": return jsonResult(host.permissions())
case "context": return jsonResult(host.context())
case "selection": return jsonResult(host.selection())
case "show": return jsonResult(host.show())
case "hide": host.hide(); return jsonResult(["ok":true])
case "status": return jsonResult(["requested":host.requested,"visible":host.panel?.isVisible ?? false])
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,26 @@ pub async fn context_capture(app: AppHandle, state: State<'_, AppState>) -> Repl
runtime::changed(&app);
Ok(context)
}
/// 底栏高频轮询用的选区快照:只读 Finder 当前选中的路径,不做目录解析与
/// 窗口校验,因此可以按几百毫秒的间隔调用;选区变化后再走完整的 context_capture。
#[tauri::command]
pub async fn selection_peek() -> Reply<Vec<String>> {
let value =
tokio::task::spawn_blocking(|| platform::host_call(json!({ "operation": "selection" })))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(value
.get("files")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
/// 底栏轮询用的轻量探测:只读取 Finder 当前上下文,不落库、不广播,
/// 前端据此判断选区是否变化后再决定是否真正捕获。
#[tauri::command]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ pub fn run() {
commands::permissions_check,
commands::context_capture,
commands::context_peek,
commands::selection_peek,
commands::choose_files,
commands::choose_directory,
commands::task_submit,
Expand Down
20 changes: 18 additions & 2 deletions src/AgentBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BorderBeam } from 'border-beam';
import { AssetIcon,SettingsIcon,SvgImage } from './icons';
import { SlashMenu,slashOptions,type SlashOption } from './SlashMenu';
import type { FleqiController } from './backend';
import { operationLabels } from './backend';
import type { Appearance } from './appearance';
import { getMaterialSupport, nativeHost, updateNativeMaterial, type MaterialSupport } from './native';
import { AppearancePanel } from './AppearancePanel';
Expand Down Expand Up @@ -103,6 +104,19 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con
// 实时底栏展示 Finder 实际捕获的选区,用户提交前就能看到会处理哪些文件。
const liveFiles=live?.files??[];
const shownFiles=live?liveFiles:files;
// 任务进行中:快捷按钮收起、输入框收成圆圈转圈,空出的横条按顺序掠过工具调用。
const running=!!live?.busy&&!live?.answerMode;
const activeTask=controller?.snapshot?.tasks.find(t=>t.id===controller.snapshot?.activeTaskId)??null;
const ticked=useRef(0);
const [ticker,setTicker]=useState<string[]>([]);
useEffect(()=>{
const actions=activeTask?.actions??[];
if(!actions.length){ticked.current=0;setTicker([]);return;}
if(actions.length<ticked.current)ticked.current=0;
const fresh=actions.slice(ticked.current).map(action=>operationLabels[action.operation]??action.operation);
ticked.current=actions.length;
if(fresh.length)setTicker(current=>[...current,...fresh].slice(-5));
},[activeTask?.id,activeTask?.actions.length]);

return (
<div className="agent-region" data-theme={appearance.theme}>
Expand All @@ -119,15 +133,16 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con
</Reveal>
<Reveal show={menuOpen} edge="bottom" className="slash-reveal"><SlashMenu options={options} index={menuIndex} busy={commandBusy} onSelect={o=>void selectCommand(o)} onHover={setMenuIndex} onSettings={()=>controller?void controller.openSettings('models'):setOpen(true)}/></Reveal>
<div ref={bar} className="agent-bar" data-native-material={nativeMaterial} data-testid="agent-bar">
<form onSubmit={submit} aria-label="Fleqi 文件操作" className="bar-controls">
<form onSubmit={submit} aria-label="Fleqi 文件操作" className="bar-controls" data-running={running?'true':undefined}>
<button type="button" className="round-button add-button" aria-label="选择文件"
title="选择文件" disabled={choosing||dispatching} onClick={async() => {if(live){setChoosing(true);try{await live.onChoose();}finally{setChoosing(false);}}else fileInput.current?.click();}}>{choosing?<LoaderCircle className="animate-spin"/>:<SvgImage name="plusCircleFill" size={22}/>}</button>
{running&&<div className="tool-strip" aria-hidden="true">{ticker.map((label,index)=><span className="tool-chip" key={`${label}-${index}`}>{label}</span>)}{!ticker.length&&<span className="tool-chip idle">正在准备…</span>}</div>}
<input ref={fileInput} className="visually-hidden" type="file" multiple tabIndex={-1} aria-label="添加文件"
onChange={(event) => { setFiles(Array.from(event.target.files ?? [], (file) => file.name)); event.target.value = ''; input.current?.focus(); }} />
<BorderBeam className="command-beam" size="md" colorVariant="mono" strength={0.7} theme={appearance.theme} active={!reducedMotion} borderRadius={24}>
<input ref={input} className="command-input" aria-label="输入文件操作指令"
placeholder={live?.answerMode?'回复 agent…':'向 Fleqi 描述要整理、改名或转换的文件…'} value={text} maxLength={4000}
aria-controls={menuOpen?'slash-options':undefined} aria-expanded={menuOpen} aria-activedescendant={menuOpen&&options.length?`slash-option-${menuIndex}`:undefined} autoComplete="off" spellCheck={false} disabled={dispatching} onChange={(event) => { setText(event.target.value); setNotice(null);setMenuDismissed(false); }}
aria-controls={menuOpen?'slash-options':undefined} aria-expanded={menuOpen} aria-activedescendant={menuOpen&&options.length?`slash-option-${menuIndex}`:undefined} autoComplete="off" spellCheck={false} disabled={dispatching||running} onChange={(event) => { setText(event.target.value); setNotice(null);setMenuDismissed(false); }}
onCompositionStart={() => { composing.current = true; }}
onCompositionEnd={() => { composing.current = false; }}
onKeyDown={(event) => {
Expand All @@ -139,6 +154,7 @@ export function AgentBar({ appearance, onAppearance, scene, layoutKey, live, con
}
if (event.key === 'Escape') { setNotice(null); input.current?.blur();if(live)void live.onHide(); }
}} />
{running&&<LoaderCircle className="running-spinner animate-spin" aria-hidden="true"/>}
</BorderBeam>
<button type="submit" className={`send-button ${live?.answerMode?'reply':''}`} aria-label={live?.answerMode?'回复 agent':'发送指令'}
disabled={!text.trim()||dispatching||(live?.busy&&!live?.answerMode)} title={live?.answerMode?'回复 agent':'发送指令'}>{(dispatching||(live?.busy&&!live?.answerMode))?<LoaderCircle className="animate-spin"/>:live?.answerMode?<span className="reply-label">回复</span>:<SvgImage name="paperplaneCircle" size={28} className="send-glyph"/>}</button>
Expand Down
Loading
Loading