feat: 浏览器面板体验修复与流式/思考组件简化

This commit is contained in:
Rick Ye
2026-09-09 02:24:25 +08:00
parent 3f9d79ca30
commit b9bb72ada9
13 changed files with 147 additions and 145 deletions
+5
View File
@@ -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
+4
View File
@@ -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) => {
+1
View File
@@ -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
+5 -1
View File
@@ -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"
{children} data-streaming={streaming ? 'true' : 'false'}
<span >
aria-hidden={!streaming} {children}
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 ? (
<div className="h-full bg-white" /> frozen ? (
<img src={frozen} alt="" aria-hidden className="h-full w-full object-cover" draggable={false} />
) : (
<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()),
+1
View File
@@ -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;