fix(desktop): lock appbar to edge
This commit is contained in:
@@ -63,31 +63,27 @@ const offlineConnection = await page.locator('.mini-connection').evaluate((eleme
|
|||||||
text: element.textContent,
|
text: element.textContent,
|
||||||
borderColor: getComputedStyle(element).borderColor,
|
borderColor: getComputedStyle(element).borderColor,
|
||||||
}))
|
}))
|
||||||
await page.screenshot({ path: 'test-results/mini-login.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/app-login.png', fullPage: true })
|
||||||
const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value))
|
const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value))
|
||||||
await page.locator('.login-note input').nth(2).press('Enter')
|
await page.locator('.login-note input').nth(2).press('Enter')
|
||||||
await page.waitForSelector('.mini-shell')
|
await page.waitForSelector('.mini-shell')
|
||||||
await page.waitForTimeout(400)
|
await page.waitForTimeout(400)
|
||||||
await page.screenshot({ path: 'test-results/mini-home-right.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/app-home.png', fullPage: true })
|
||||||
|
|
||||||
const home = await page.evaluate(() => ({
|
const home = await page.evaluate(() => ({
|
||||||
width: document.querySelector('.mini-shell')?.getBoundingClientRect().width,
|
width: document.querySelector('.mini-shell')?.getBoundingClientRect().width,
|
||||||
navCount: document.querySelectorAll('.mini-nav button').length,
|
navCount: document.querySelectorAll('.mini-nav button').length,
|
||||||
dock: document.documentElement.dataset.dock,
|
|
||||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
title: document.querySelector('.page-intro h4')?.textContent ?? '',
|
title: document.querySelector('.page-intro h4')?.textContent ?? '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await page.getByRole('button', { name: '停靠左侧' }).click()
|
|
||||||
const leftDock = await page.evaluate(() => document.documentElement.dataset.dock)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: '计划', exact: true }).click()
|
await page.getByRole('button', { name: '计划', exact: true }).click()
|
||||||
await page.getByRole('button', { name: '设计', exact: true }).click()
|
await page.getByRole('button', { name: '设计', exact: true }).click()
|
||||||
const tasks = await page.evaluate(() => ({
|
const tasks = await page.evaluate(() => ({
|
||||||
count: document.querySelectorAll('.task-note').length,
|
count: document.querySelectorAll('.task-note').length,
|
||||||
title: document.querySelector('.task-note strong')?.textContent ?? '',
|
title: document.querySelector('.task-note strong')?.textContent ?? '',
|
||||||
}))
|
}))
|
||||||
await page.screenshot({ path: 'test-results/mini-tasks.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/app-tasks.png', fullPage: true })
|
||||||
|
|
||||||
await page.getByRole('button', { name: '资料', exact: true }).click()
|
await page.getByRole('button', { name: '资料', exact: true }).click()
|
||||||
const notes = await page.locator('.source-note').count()
|
const notes = await page.locator('.source-note').count()
|
||||||
@@ -96,7 +92,7 @@ await page.getByRole('button', { name: 'AI', exact: true }).click()
|
|||||||
await page.getByRole('button', { name: /产品经理/ }).click()
|
await page.getByRole('button', { name: /产品经理/ }).click()
|
||||||
await page.locator('.mini-composer textarea').fill('请给出下一步计划')
|
await page.locator('.mini-composer textarea').fill('请给出下一步计划')
|
||||||
const sendEnabled = await page.getByRole('button', { name: '开始会话' }).isEnabled()
|
const sendEnabled = await page.getByRole('button', { name: '开始会话' }).isEnabled()
|
||||||
await page.screenshot({ path: 'test-results/mini-ai.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/app-ai.png', fullPage: true })
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
await server.close()
|
await server.close()
|
||||||
@@ -107,12 +103,11 @@ if (loginDefaults[1] !== 'demo@senlin.ai' || loginDefaults[2] !== 'password123')
|
|||||||
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
||||||
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
|
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
|
||||||
if (home.navCount !== 5) failures.push(`expected five top navigation items, got ${home.navCount}`)
|
if (home.navCount !== 5) failures.push(`expected five top navigation items, got ${home.navCount}`)
|
||||||
if (home.dock !== 'right' || leftDock !== 'left') failures.push(`dock state failed: right=${home.dock}, left=${leftDock}`)
|
|
||||||
if (home.overflowX) failures.push('mini client has horizontal overflow')
|
if (home.overflowX) failures.push('mini client has horizontal overflow')
|
||||||
if (home.title !== '森林项目') failures.push(`project overview missing, got ${home.title}`)
|
if (home.title !== '森林项目') failures.push(`project overview missing, got ${home.title}`)
|
||||||
if (tasks.count !== 1 || tasks.title !== '检查迷你布局') failures.push(`task tag filtering failed: ${JSON.stringify(tasks)}`)
|
if (tasks.count !== 1 || tasks.title !== '检查迷你布局') failures.push(`task tag filtering failed: ${JSON.stringify(tasks)}`)
|
||||||
if (notes !== 2) failures.push(`expected two note cards, got ${notes}`)
|
if (notes !== 2) failures.push(`expected two note cards, got ${notes}`)
|
||||||
if (!sendEnabled) failures.push('AI composer must enable after selecting an expert and entering a prompt')
|
if (!sendEnabled) failures.push('AI composer must enable after selecting an expert and entering a prompt')
|
||||||
|
|
||||||
console.log(JSON.stringify({ home, leftDock, tasks, notes, sendEnabled, errors }, null, 2))
|
console.log(JSON.stringify({ home, tasks, notes, sendEnabled, errors }, null, 2))
|
||||||
if (failures.length) throw new Error(failures.join('\n'))
|
if (failures.length) throw new Error(failures.join('\n'))
|
||||||
|
|||||||
@@ -1,49 +1,9 @@
|
|||||||
use tauri::Window;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
#[cfg(not(windows))]
|
|
||||||
use tauri::{PhysicalPosition, PhysicalSize};
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod windows_appbar;
|
mod windows_appbar;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn dock_window(window: Window, side: String) -> Result<(), String> {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let hwnd = window.hwnd().map_err(|error| error.to_string())?;
|
|
||||||
return windows_appbar::set_edge(hwnd, side == "left").map_err(|error| error.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
{
|
|
||||||
let monitor = window
|
|
||||||
.current_monitor()
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
.ok_or_else(|| "Unable to identify the current monitor".to_string())?;
|
|
||||||
let window_size = window.outer_size().map_err(|error| error.to_string())?;
|
|
||||||
let work_area = monitor.work_area();
|
|
||||||
let dock_size = PhysicalSize::new(window_size.width, work_area.size.height);
|
|
||||||
let x = if side == "left" {
|
|
||||||
work_area.position.x
|
|
||||||
} else {
|
|
||||||
work_area.position.x + work_area.size.width as i32 - dock_size.width as i32
|
|
||||||
};
|
|
||||||
|
|
||||||
window
|
|
||||||
.set_size(dock_size)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
window
|
|
||||||
.set_position(PhysicalPosition::new(x, work_area.position.y))
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
window
|
|
||||||
.set_always_on_top(true)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -58,7 +18,6 @@ pub fn run() {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![dock_window])
|
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("failed to build SenlinAI App")
|
.expect("failed to build SenlinAI App")
|
||||||
.run(|_, event| {
|
.run(|_, event| {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use windows::{
|
|||||||
UI::{
|
UI::{
|
||||||
Shell::{
|
Shell::{
|
||||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
||||||
ABE_LEFT, ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
||||||
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
||||||
},
|
},
|
||||||
WindowsAndMessaging::{
|
WindowsAndMessaging::{
|
||||||
@@ -27,7 +27,6 @@ const SUBCLASS_ID: usize = 0x5341_4142; // "SAAB" (SenlinAI AppBar)
|
|||||||
|
|
||||||
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
||||||
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||||
static EDGE: AtomicU32 = AtomicU32::new(ABE_RIGHT);
|
|
||||||
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Registers the main App window as a right-edge AppBar.
|
/// Registers the main App window as a right-edge AppBar.
|
||||||
@@ -65,12 +64,6 @@ pub fn register(hwnd: HWND) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repositions the AppBar after the App UI selects the left or right edge.
|
|
||||||
pub fn set_edge(hwnd: HWND, left: bool) -> Result<()> {
|
|
||||||
EDGE.store(if left { ABE_LEFT } else { ABE_RIGHT }, Ordering::Release);
|
|
||||||
layout(hwnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
||||||
pub fn unregister() {
|
pub fn unregister() {
|
||||||
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
||||||
@@ -137,23 +130,14 @@ fn layout_inner(hwnd: HWND) -> Result<()> {
|
|||||||
};
|
};
|
||||||
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
||||||
|
|
||||||
let edge = EDGE.load(Ordering::Acquire);
|
|
||||||
let mut data = appbar_data(hwnd);
|
let mut data = appbar_data(hwnd);
|
||||||
data.uEdge = edge;
|
data.uEdge = ABE_RIGHT;
|
||||||
data.rc = monitor_info.rcMonitor;
|
data.rc = monitor_info.rcMonitor;
|
||||||
if edge == ABE_LEFT {
|
data.rc.left = data.rc.right - width;
|
||||||
data.rc.right = data.rc.left + width;
|
|
||||||
} else {
|
|
||||||
data.rc.left = data.rc.right - width;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
||||||
if edge == ABE_LEFT {
|
data.rc.left = data.rc.right - width;
|
||||||
data.rc.right = data.rc.left + width;
|
|
||||||
} else {
|
|
||||||
data.rc.left = data.rc.right - width;
|
|
||||||
}
|
|
||||||
SHAppBarMessage(ABM_SETPOS, &mut data);
|
SHAppBarMessage(ABM_SETPOS, &mut data);
|
||||||
SetWindowPos(
|
SetWindowPos(
|
||||||
hwnd,
|
hwnd,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react'
|
import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||||
import {
|
import {
|
||||||
IconApps, IconArrowLeft, IconArrowRight, IconCheckCircle, IconClockCircle,
|
IconApps, IconCheckCircle, IconClockCircle,
|
||||||
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
||||||
} from '@arco-design/web-react/icon'
|
} from '@arco-design/web-react/icon'
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
|
|
||||||
const { Text, Title } = Typography
|
const { Text, Title } = Typography
|
||||||
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more'
|
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more'
|
||||||
type DockSide = 'left' | 'right'
|
|
||||||
type Action = 'project' | 'task' | 'cron' | null
|
type Action = 'project' | 'task' | 'cron' | null
|
||||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||||
|
|
||||||
@@ -32,21 +31,11 @@ export function App() {
|
|||||||
const [experts, setExperts] = useState<Expert[]>([])
|
const [experts, setExperts] = useState<Expert[]>([])
|
||||||
const [aiSessions, setAISessions] = useState<AISession[]>([])
|
const [aiSessions, setAISessions] = useState<AISession[]>([])
|
||||||
const [view, setView] = useState<View>('home')
|
const [view, setView] = useState<View>('home')
|
||||||
const [dockSide, setDockSide] = useState<DockSide>(() => {
|
|
||||||
if ('__TAURI_INTERNALS__' in window) return 'right'
|
|
||||||
return localStorage.getItem('senlin-mini-dock') === 'left' ? 'left' : 'right'
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(Boolean(session))
|
const [loading, setLoading] = useState(Boolean(session))
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [action, setAction] = useState<Action>(null)
|
const [action, setAction] = useState<Action>(null)
|
||||||
const refreshGeneration = useRef(0)
|
const refreshGeneration = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.dataset.dock = dockSide
|
|
||||||
localStorage.setItem('senlin-mini-dock', dockSide)
|
|
||||||
void moveDesktopWindow(dockSide)
|
|
||||||
}, [dockSide])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -89,10 +78,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dock(side: DockSide) {
|
|
||||||
setDockSide(side)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) return <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} />
|
if (!session) return <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} />
|
||||||
|
|
||||||
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0]
|
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0]
|
||||||
@@ -112,10 +97,6 @@ export function App() {
|
|||||||
<span>森林AI</span>
|
<span>森林AI</span>
|
||||||
<Tag size="small" color="arcoblue">App 1.0</Tag>
|
<Tag size="small" color="arcoblue">App 1.0</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="window-actions">
|
|
||||||
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
|
|
||||||
<Button aria-label="停靠右侧" className={dockSide === 'right' ? 'active' : ''} type="text" size="mini" icon={<IconArrowRight />} onClick={() => void dock('right')} />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="project-switcher">
|
<div className="project-switcher">
|
||||||
@@ -415,13 +396,3 @@ function errorMessage(error: unknown) {
|
|||||||
if (error instanceof ApiError || error instanceof Error) return error.message
|
if (error instanceof ApiError || error instanceof Error) return error.message
|
||||||
return '操作失败,请稍后重试'
|
return '操作失败,请稍后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveDesktopWindow(side: DockSide) {
|
|
||||||
if (!('__TAURI_INTERNALS__' in window)) return
|
|
||||||
try {
|
|
||||||
const { invoke } = await import('@tauri-apps/api/core')
|
|
||||||
await invoke('dock_window', { side })
|
|
||||||
} catch (requestError) {
|
|
||||||
Message.warning(errorMessage(requestError))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,19 +17,16 @@ button, input, textarea { font: inherit; }
|
|||||||
button { color: inherit; }
|
button { color: inherit; }
|
||||||
|
|
||||||
.mini-shell {
|
.mini-shell {
|
||||||
width: min(100%, 440px);
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px;
|
grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
border: 1px solid rgba(78, 89, 105, 0.18);
|
border: 0;
|
||||||
box-shadow: 0 20px 60px rgba(29, 33, 41, 0.18);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-dock="right"] .mini-shell { margin-left: auto; }
|
|
||||||
html[data-dock="left"] .mini-shell { margin-right: auto; }
|
|
||||||
|
|
||||||
.mini-header,
|
.mini-header,
|
||||||
.project-switcher,
|
.project-switcher,
|
||||||
.mini-nav,
|
.mini-nav,
|
||||||
@@ -47,7 +44,6 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-brand,
|
.mini-brand,
|
||||||
.window-actions,
|
|
||||||
.project-trigger,
|
.project-trigger,
|
||||||
.mini-footer,
|
.mini-footer,
|
||||||
.section-title,
|
.section-title,
|
||||||
@@ -63,9 +59,6 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
|||||||
.mini-brand { gap: 7px; font-weight: 800; }
|
.mini-brand { gap: 7px; font-weight: 800; }
|
||||||
.mini-brand img { width: 25px; height: 25px; }
|
.mini-brand img { width: 25px; height: 25px; }
|
||||||
.mini-brand .arco-tag { margin-left: 2px; }
|
.mini-brand .arco-tag { margin-left: 2px; }
|
||||||
.window-actions { gap: 2px; }
|
|
||||||
.window-actions .arco-btn { color: #6b778c; }
|
|
||||||
.window-actions .active { color: var(--blue); background: #edf3ff; }
|
|
||||||
|
|
||||||
.project-switcher {
|
.project-switcher {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -236,14 +229,7 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
|||||||
.mini-form { display: grid; gap: 13px; }
|
.mini-form { display: grid; gap: 13px; }
|
||||||
.mini-form .arco-select { width: 100%; }
|
.mini-form .arco-select { width: 100%; }
|
||||||
|
|
||||||
@media (min-width: 700px) {
|
|
||||||
body { display: flex; }
|
|
||||||
html[data-dock="right"] body { justify-content: flex-end; }
|
|
||||||
html[data-dock="left"] body { justify-content: flex-start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 359px) {
|
@media (max-width: 359px) {
|
||||||
.mini-shell { border: 0; }
|
|
||||||
.mini-brand .arco-tag { display: none; }
|
.mini-brand .arco-tag { display: none; }
|
||||||
.note-grid, .expert-strip { grid-template-columns: 1fr; }
|
.note-grid, .expert-strip { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user