feat: 浏览器面板体验修复与流式/思考组件简化
This commit is contained in:
@@ -45,6 +45,11 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
// Windows 上 Hyper-V/WSL 的 WinNAT 每次开机会随机保留若干端口段,
|
||||||
|
// 默认 5173 常被圈进保留段导致 EACCES;固定用 5323 避开(若日后再撞,
|
||||||
|
// 可换端口,或管理员执行 net stop winnat && net start winnat 后
|
||||||
|
// netsh int ipv4 add excludedportrange protocol=tcp startport=<端口> numberofports=1 永久占用)
|
||||||
|
server: { port: 5323 },
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const PARTITION = 'persist:aso-browser'
|
const PARTITION = 'persist:aso-browser'
|
||||||
const NEW_TAB_URL = 'https://www.google.com/'
|
// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏,
|
||||||
|
// 误以为浏览器故障;用可稳定访问的导航页
|
||||||
|
const NEW_TAB_URL = 'https://www.baidu.com/'
|
||||||
const MAX_TABS = 12
|
const MAX_TABS = 12
|
||||||
const MAX_FILL_CHARS = 10_000
|
const MAX_FILL_CHARS = 10_000
|
||||||
const MAX_SCRIPT_CHARS = 20_000
|
const MAX_SCRIPT_CHARS = 20_000
|
||||||
@@ -932,6 +934,15 @@ export class BrowserController {
|
|||||||
return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() }
|
return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 渲染层「冻结帧」用:菜单/浮层需要暂时隐藏原生视图前,截当前画面做占位,
|
||||||
|
* 视觉上等于菜单浮在页面之上(DOM 层级永远低于原生视图,菜单弹出必须先让位) */
|
||||||
|
async captureTabDataUrl(tabId?: string): Promise<string> {
|
||||||
|
const tab = tabId ? this.tabs.get(tabId) : this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
||||||
|
if (!tab) throw new Error('标签页不存在,无法截取画面。')
|
||||||
|
const image = await tab.view.webContents.capturePage()
|
||||||
|
return image.toDataURL()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BROWSER_OBSERVE_DEPTH_FALLBACK = 400
|
const MAX_BROWSER_OBSERVE_DEPTH_FALLBACK = 400
|
||||||
|
|||||||
@@ -332,6 +332,10 @@ export function registerIpc(): AgentService {
|
|||||||
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
|
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
|
||||||
)
|
)
|
||||||
ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close()))
|
ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close()))
|
||||||
|
// 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位
|
||||||
|
ipcMain.handle('browser:captureTab', (_e, tabId: unknown) =>
|
||||||
|
browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined))
|
||||||
|
)
|
||||||
ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
|
ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
|
||||||
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
|
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
|
||||||
ipcMain.handle('browser:layout', (_e, layout: unknown) => {
|
ipcMain.handle('browser:layout', (_e, layout: unknown) => {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const api = {
|
|||||||
browserSelectTab: (tabId: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:selectTab', tabId),
|
browserSelectTab: (tabId: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:selectTab', tabId),
|
||||||
browserCloseTab: (tabId?: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:closeTab', tabId),
|
browserCloseTab: (tabId?: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:closeTab', tabId),
|
||||||
browserClose: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:close'),
|
browserClose: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:close'),
|
||||||
|
browserCaptureTab: (tabId?: string): Promise<string> => ipcRenderer.invoke('browser:captureTab', tabId),
|
||||||
browserAcknowledgeRisk: (): Promise<void> => ipcRenderer.invoke('browser:acknowledgeRisk'),
|
browserAcknowledgeRisk: (): Promise<void> => ipcRenderer.invoke('browser:acknowledgeRisk'),
|
||||||
browserSetLayout: (layout: {
|
browserSetLayout: (layout: {
|
||||||
tabId: string
|
tabId: string
|
||||||
|
|||||||
@@ -115,11 +115,14 @@ export default function App(): JSX.Element {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const closePanelTab = useCallback((id: string): void => {
|
const closePanelTab = useCallback((id: string): void => {
|
||||||
|
// 关闭浏览器面板标签 = 关闭全部受管页面(引擎=persist 分区会话保留:
|
||||||
|
// 登录态/Cookie 不丢,重新打开标签即可用;Agent 后续浏览器操作会自动重开新页)
|
||||||
|
if (id === 'browser') browser.closeBrowser()
|
||||||
setSide((s) => {
|
setSide((s) => {
|
||||||
const tabs = s.tabs.filter((t) => t !== id)
|
const tabs = s.tabs.filter((t) => t !== id)
|
||||||
return { ...s, tabs, active: s.active === id ? (tabs[tabs.length - 1] ?? null) : s.active }
|
return { ...s, tabs, active: s.active === id ? (tabs[tabs.length - 1] ?? null) : s.active }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [browser.closeBrowser])
|
||||||
|
|
||||||
const openPanelTab = useCallback((id: string): void => {
|
const openPanelTab = useCallback((id: string): void => {
|
||||||
setSide((s) => ({
|
setSide((s) => ({
|
||||||
@@ -205,6 +208,7 @@ export default function App(): JSX.Element {
|
|||||||
onNewTab={browser.newTab}
|
onNewTab={browser.newTab}
|
||||||
onSelectTab={browser.selectTab}
|
onSelectTab={browser.selectTab}
|
||||||
onCloseTab={browser.closeTab}
|
onCloseTab={browser.closeTab}
|
||||||
|
onCloseAllTabs={browser.closeBrowser}
|
||||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||||
onDetectSources={browser.detectSources}
|
onDetectSources={browser.detectSources}
|
||||||
onImportProfile={browser.importProfile}
|
onImportProfile={browser.importProfile}
|
||||||
|
|||||||
@@ -98,9 +98,13 @@ export function SidePanel({
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [listOpen, plusOpen, ctxMenu])
|
}, [listOpen, plusOpen, ctxMenu])
|
||||||
|
|
||||||
// 覆盖层开合上报(宿主借此暂停原生视图)
|
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
|
||||||
|
// 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行),
|
||||||
|
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
|
||||||
|
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null)
|
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null)
|
||||||
|
return () => onOverlayChange?.(false)
|
||||||
}, [listOpen, plusOpen, ctxMenu, onOverlayChange])
|
}, [listOpen, plusOpen, ctxMenu, onOverlayChange])
|
||||||
|
|
||||||
const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null
|
const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null
|
||||||
|
|||||||
@@ -1,99 +1,25 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** 流式回答(beautifului.dev Streaming Text)。
|
/** 流式回答容器(beautifului.dev Streaming Text)。
|
||||||
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
|
* 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after
|
||||||
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
|
* 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失,
|
||||||
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
|
* 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。
|
||||||
|
* 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行:
|
||||||
|
* 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */
|
||||||
export function StreamingText({
|
export function StreamingText({
|
||||||
children,
|
children,
|
||||||
streaming,
|
streaming
|
||||||
text,
|
|
||||||
followUps,
|
|
||||||
onFollowUp
|
|
||||||
}: {
|
}: {
|
||||||
/** 正文内容(通常为 <Markdown/>) */
|
/** 正文内容(通常为 <Markdown/>) */
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** 已渲染的纯文本,用于复制;为空时不显示操作行 */
|
|
||||||
text?: string
|
|
||||||
followUps?: string[]
|
|
||||||
onFollowUp?: (text: string) => void
|
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copied) return
|
|
||||||
const t = setTimeout(() => setCopied(false), 1600)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [copied])
|
|
||||||
|
|
||||||
const copy = (): void => {
|
|
||||||
void navigator.clipboard.writeText(text ?? '').then(() => setCopied(true))
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = !streaming && Boolean(text)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div
|
||||||
<div className="text-base leading-normal text-ink">
|
className="streaming-body min-w-0 text-base leading-normal text-ink"
|
||||||
|
data-streaming={streaming ? 'true' : 'false'}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<span
|
|
||||||
aria-hidden={!streaming}
|
|
||||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink align-baseline"
|
|
||||||
style={{
|
|
||||||
animation: 'fade-in 150ms ease-out both',
|
|
||||||
opacity: streaming ? undefined : 0,
|
|
||||||
transition: 'opacity 400ms ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作行:流式结束后淡入。流式期间不渲染(opacity:0 也会占位,
|
|
||||||
* 每个正文段后会挂一块约 32px 的隐形空白,且完成后卸载造成布局跳动) */}
|
|
||||||
{done && (
|
|
||||||
<div className="mt-2 flex items-center gap-0.5" style={{ animation: 'fade-in 400ms ease both' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copied ? '已复制' : '复制回答'}
|
|
||||||
onClick={copy}
|
|
||||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M20 6L9 17l-5-5" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="9" y="9" width="12" height="12" rx="2.5" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Follow-ups:完成后的跟进问题列表 */}
|
|
||||||
{done && followUps && followUps.length > 0 && (
|
|
||||||
<div className="mt-2.5" style={{ animation: 'fade-in 400ms ease both' }}>
|
|
||||||
<p className="text-[12px] font-medium text-ink-2">继续问</p>
|
|
||||||
<div className="mt-0.5 flex flex-col">
|
|
||||||
{followUps.map((q) => (
|
|
||||||
<button
|
|
||||||
key={q}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFollowUp?.(q)}
|
|
||||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2"
|
|
||||||
>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
|
||||||
<path d="M9 10l-5 5 5 5" />
|
|
||||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
|
||||||
</svg>
|
|
||||||
{q}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
||||||
* 流式中默认收起:表头「正在思考 · 最新一行」持续刷新,不展示全部;
|
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由
|
||||||
* 手动展开时限高滚动预览——新内容在尾部并自动吸底,用户上滚即取消吸底
|
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。
|
||||||
* (滚回底部恢复)。完成后默认展开完整展示全部思考,表头「思考 · 首行」。
|
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||||
|
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||||
|
* 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演;
|
||||||
|
* 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。
|
||||||
|
* 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成
|
||||||
|
* 面板可见缩短。rows 由真实思考文本逐行喂入。 */
|
||||||
export function Thinking({
|
export function Thinking({
|
||||||
rows,
|
rows,
|
||||||
streaming,
|
streaming,
|
||||||
@@ -17,32 +22,9 @@ export function Thinking({
|
|||||||
activeLabel?: string
|
activeLabel?: string
|
||||||
doneLabel?: string
|
doneLabel?: string
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
// 流式中默认收起,完成后默认展开;用户点击后以手动状态为准
|
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准
|
||||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||||
const expanded = manualExpanded ?? !streaming
|
const expanded = manualExpanded ?? true
|
||||||
|
|
||||||
// 流式轨迹的行必须「随到随显」:fade-up 的 both+delay 会让新行在延迟期间
|
|
||||||
// 透明但占高,流式期间底部因此持续留出一大片空白。交错入场只保留给
|
|
||||||
// 非流式一次性渲染的静态轨迹(挂载时决定,之后不变更,避免整段重播动画)。
|
|
||||||
const streamedMountRef = useRef(streaming)
|
|
||||||
|
|
||||||
// 流式展开时的滚动吸底:新内容到达且当前贴着底部时跟随;用户上滚即取消,
|
|
||||||
// 滚回底部恢复。收起时重置吸底,重新展开直接落在最新内容。完成后不限高。
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const pinnedRef = useRef(true)
|
|
||||||
const handleScroll = (): void => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
if (!expanded) {
|
|
||||||
pinnedRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (el && pinnedRef.current && streaming) el.scrollTop = el.scrollHeight
|
|
||||||
}, [rows, expanded, streaming])
|
|
||||||
|
|
||||||
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
|
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
|
||||||
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||||
@@ -50,12 +32,12 @@ export function Thinking({
|
|||||||
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
|
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-95 flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
|
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => setManualExpanded((current) => !(current ?? streaming))}
|
onClick={() => setManualExpanded((current) => !(current ?? true))}
|
||||||
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||||
@@ -101,7 +83,7 @@ export function Thinking({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 展开轨迹:流式时限高滚动预览(吸底跟随),完成后完整展示全部思考 */}
|
{/* 展开轨迹:不限高完整展示全部思考(收起由外层过程区承担) */}
|
||||||
<div
|
<div
|
||||||
className="grid transition-[grid-template-rows,opacity]"
|
className="grid transition-[grid-template-rows,opacity]"
|
||||||
style={{
|
style={{
|
||||||
@@ -111,11 +93,7 @@ export function Thinking({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-h-0 overflow-hidden">
|
<div className="min-h-0 overflow-hidden">
|
||||||
<div
|
<div className="mt-1 ml-[5px] pl-4">
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className={`mt-1 ml-[5px] overflow-y-auto pl-4 ${streaming ? 'max-h-[220px]' : 'max-h-[400px]'}`}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
|
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
|
||||||
<div className="flex flex-col gap-1 py-1">
|
<div className="flex flex-col gap-1 py-1">
|
||||||
@@ -123,11 +101,7 @@ export function Thinking({
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
|
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
|
||||||
style={{
|
style={{ animation: 'fade-up 250ms ease' }}
|
||||||
animation: streamedMountRef.current
|
|
||||||
? 'fade-up 250ms ease'
|
|
||||||
: `fade-up 400ms ease ${Math.min(i, 8) * 120}ms both`
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
|
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
|
||||||
{row.primary}
|
{row.primary}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface BrowserPanelProps {
|
|||||||
onNewTab: (url?: string) => void
|
onNewTab: (url?: string) => void
|
||||||
onSelectTab: (tabId: string) => void
|
onSelectTab: (tabId: string) => void
|
||||||
onCloseTab: (tabId: string) => void
|
onCloseTab: (tabId: string) => void
|
||||||
|
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
||||||
|
onCloseAllTabs: () => void
|
||||||
onAcknowledgeRisk: () => void
|
onAcknowledgeRisk: () => void
|
||||||
onDetectSources: () => Promise<BrowserSource[]>
|
onDetectSources: () => Promise<BrowserSource[]>
|
||||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||||
@@ -77,9 +79,54 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const riskBanner = state && !state.riskAcknowledged
|
const riskBanner = state && !state.riskAcknowledged
|
||||||
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时隐藏(原生层级永远在 DOM 之上)
|
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
||||||
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
|
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
|
||||||
const showSlot = Boolean(activeTab) && !suspended && !overlayOpen
|
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
|
||||||
|
const hideCause = suspended || overlayOpen
|
||||||
|
|
||||||
|
// 冻结帧:让位前先截当前页面画面,隐藏期间以快照占位——视觉上等于菜单浮在
|
||||||
|
// 页面之上。直接白屏占位会让整页「消失」,看起来像浏览器故障(此前报告的
|
||||||
|
// 「展开标签列表时页面区域空白」即由此而来)。顺序:先截帧(视图仍可见),
|
||||||
|
// 下一拍再隐藏;恢复时先挂回视图、快照被盖住后再清,全程不闪白。
|
||||||
|
const [frozen, setFrozen] = useState<string | null>(null)
|
||||||
|
const [slotHidden, setSlotHidden] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tabId = activeTab?.tabId
|
||||||
|
if (!tabId) {
|
||||||
|
setSlotHidden(false)
|
||||||
|
setFrozen(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let alive = true
|
||||||
|
if (hideCause && !slotHidden) {
|
||||||
|
void window.api
|
||||||
|
.browserCaptureTab(tabId)
|
||||||
|
.then((dataUrl) => {
|
||||||
|
if (alive && dataUrl) setFrozen(dataUrl)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (alive) setSlotHidden(true)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hideCause && slotHidden) {
|
||||||
|
setSlotHidden(false)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (alive) setFrozen(null)
|
||||||
|
}, 200)
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [hideCause, slotHidden, activeTab?.tabId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-w-0 flex-1 flex-col bg-zinc-50" aria-label="内置浏览器">
|
<div className="flex h-full min-w-0 flex-1 flex-col bg-zinc-50" aria-label="内置浏览器">
|
||||||
@@ -135,6 +182,20 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{(state?.tabs ?? []).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
props.onCloseAllTabs()
|
||||||
|
setTabListOpen(false)
|
||||||
|
}}
|
||||||
|
className="mt-0.5 flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
<LuX size={11} className="shrink-0" />
|
||||||
|
关闭全部标签页
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{(state?.recentClosed?.length ?? 0) > 0 && (
|
{(state?.recentClosed?.length ?? 0) > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-zinc-100" />
|
<div className="my-1 border-t border-zinc-100" />
|
||||||
@@ -323,12 +384,16 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
||||||
* 暂停期(面板/页签菜单打开)保持纯白,不闪空态图标 */}
|
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */}
|
||||||
<div className="relative min-h-0 flex-1 bg-white">
|
<div className="relative min-h-0 flex-1 bg-white">
|
||||||
{showSlot && activeTab ? (
|
{activeTab && !slotHidden ? (
|
||||||
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
||||||
) : activeTab ? (
|
) : activeTab ? (
|
||||||
|
frozen ? (
|
||||||
|
<img src={frozen} alt="" aria-hidden className="h-full w-full object-cover" draggable={false} />
|
||||||
|
) : (
|
||||||
<div className="h-full bg-white" />
|
<div className="h-full bg-white" />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||||
<LuGlobe size={28} className="text-zinc-300" />
|
<LuGlobe size={28} className="text-zinc-300" />
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import * as React from 'react'
|
|||||||
|
|
||||||
let revisionCounter = 0
|
let revisionCounter = 0
|
||||||
function nextRevision(): number {
|
function nextRevision(): number {
|
||||||
revisionCounter += 1
|
// 以 Date.now() 为底:修订号必须跨模块热替换/整页重载单调递增——主进程
|
||||||
|
// 不随渲染层重载,各标签页还记着旧 lastLayoutRevision,纯自增计数器在
|
||||||
|
// 重载后归零会被 revision <= last 拒绝,原生视图从此收不到显示指令(页面空白)
|
||||||
|
revisionCounter = Math.max(revisionCounter + 1, Date.now())
|
||||||
return revisionCounter
|
return revisionCounter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function useBrowser(): {
|
|||||||
goBack: wrap(() => window.api.browserGoBack()),
|
goBack: wrap(() => window.api.browserGoBack()),
|
||||||
goForward: wrap(() => window.api.browserGoForward()),
|
goForward: wrap(() => window.api.browserGoForward()),
|
||||||
reload: wrap(() => window.api.browserReload()),
|
reload: wrap(() => window.api.browserReload()),
|
||||||
newTab: wrap(() => window.api.browserNewTab()),
|
newTab: wrap((url?: string) => window.api.browserNewTab(url)),
|
||||||
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
||||||
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
||||||
closeBrowser: wrap(() => window.api.browserClose()),
|
closeBrowser: wrap(() => window.api.browserClose()),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ declare global {
|
|||||||
browserSelectTab: (tabId: string) => Promise<BrowserUiState>
|
browserSelectTab: (tabId: string) => Promise<BrowserUiState>
|
||||||
browserCloseTab: (tabId?: string) => Promise<BrowserUiState>
|
browserCloseTab: (tabId?: string) => Promise<BrowserUiState>
|
||||||
browserClose: () => Promise<BrowserUiState>
|
browserClose: () => Promise<BrowserUiState>
|
||||||
|
browserCaptureTab: (tabId?: string) => Promise<string>
|
||||||
browserAcknowledgeRisk: () => Promise<void>
|
browserAcknowledgeRisk: () => Promise<void>
|
||||||
browserSetLayout: (layout: {
|
browserSetLayout: (layout: {
|
||||||
tabId: string
|
tabId: string
|
||||||
|
|||||||
@@ -52,12 +52,16 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-caret {
|
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
|
||||||
|
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */
|
||||||
|
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
|
||||||
|
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
|
||||||
|
content: '';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.5em;
|
width: 2px;
|
||||||
height: 1.05em;
|
height: 1em;
|
||||||
margin-left: 2px;
|
margin-left: 3px;
|
||||||
vertical-align: text-bottom;
|
vertical-align: -0.15em;
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
animation: caret-blink 0.9s steps(1) infinite;
|
animation: caret-blink 0.9s steps(1) infinite;
|
||||||
|
|||||||
Reference in New Issue
Block a user