feat: 浏览器面板体验修复与流式/思考组件简化
This commit is contained in:
@@ -45,6 +45,11 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
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()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
*/
|
||||
|
||||
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_FILL_CHARS = 10_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() }
|
||||
})
|
||||
}
|
||||
|
||||
/** 渲染层「冻结帧」用:菜单/浮层需要暂时隐藏原生视图前,截当前画面做占位,
|
||||
* 视觉上等于菜单浮在页面之上(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
|
||||
|
||||
@@ -332,6 +332,10 @@ export function registerIpc(): AgentService {
|
||||
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
|
||||
)
|
||||
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()))
|
||||
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
|
||||
ipcMain.handle('browser:layout', (_e, layout: unknown) => {
|
||||
|
||||
@@ -98,6 +98,7 @@ const api = {
|
||||
browserSelectTab: (tabId: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:selectTab', tabId),
|
||||
browserCloseTab: (tabId?: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:closeTab', tabId),
|
||||
browserClose: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:close'),
|
||||
browserCaptureTab: (tabId?: string): Promise<string> => ipcRenderer.invoke('browser:captureTab', tabId),
|
||||
browserAcknowledgeRisk: (): Promise<void> => ipcRenderer.invoke('browser:acknowledgeRisk'),
|
||||
browserSetLayout: (layout: {
|
||||
tabId: string
|
||||
|
||||
@@ -115,11 +115,14 @@ export default function App(): JSX.Element {
|
||||
}, [])
|
||||
|
||||
const closePanelTab = useCallback((id: string): void => {
|
||||
// 关闭浏览器面板标签 = 关闭全部受管页面(引擎=persist 分区会话保留:
|
||||
// 登录态/Cookie 不丢,重新打开标签即可用;Agent 后续浏览器操作会自动重开新页)
|
||||
if (id === 'browser') browser.closeBrowser()
|
||||
setSide((s) => {
|
||||
const tabs = s.tabs.filter((t) => t !== id)
|
||||
return { ...s, tabs, active: s.active === id ? (tabs[tabs.length - 1] ?? null) : s.active }
|
||||
})
|
||||
}, [])
|
||||
}, [browser.closeBrowser])
|
||||
|
||||
const openPanelTab = useCallback((id: string): void => {
|
||||
setSide((s) => ({
|
||||
@@ -205,6 +208,7 @@ export default function App(): JSX.Element {
|
||||
onNewTab={browser.newTab}
|
||||
onSelectTab={browser.selectTab}
|
||||
onCloseTab={browser.closeTab}
|
||||
onCloseAllTabs={browser.closeBrowser}
|
||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||
onDetectSources={browser.detectSources}
|
||||
onImportProfile={browser.importProfile}
|
||||
|
||||
@@ -98,9 +98,13 @@ export function SidePanel({
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [listOpen, plusOpen, ctxMenu])
|
||||
|
||||
// 覆盖层开合上报(宿主借此暂停原生视图)
|
||||
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
|
||||
// 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行),
|
||||
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
|
||||
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
|
||||
useEffect(() => {
|
||||
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null)
|
||||
return () => onOverlayChange?.(false)
|
||||
}, [listOpen, plusOpen, ctxMenu, onOverlayChange])
|
||||
|
||||
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)。
|
||||
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
|
||||
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
|
||||
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
|
||||
/** 流式回答容器(beautifului.dev Streaming Text)。
|
||||
* 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after
|
||||
* 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失,
|
||||
* 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。
|
||||
* 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行:
|
||||
* 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */
|
||||
export function StreamingText({
|
||||
children,
|
||||
streaming,
|
||||
text,
|
||||
followUps,
|
||||
onFollowUp
|
||||
streaming
|
||||
}: {
|
||||
/** 正文内容(通常为 <Markdown/>) */
|
||||
children?: ReactNode
|
||||
streaming: boolean
|
||||
/** 已渲染的纯文本,用于复制;为空时不显示操作行 */
|
||||
text?: string
|
||||
followUps?: string[]
|
||||
onFollowUp?: (text: string) => void
|
||||
}): 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 (
|
||||
<div className="min-w-0">
|
||||
<div className="text-base leading-normal text-ink">
|
||||
{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
|
||||
className="streaming-body min-w-0 text-base leading-normal text-ink"
|
||||
data-streaming={streaming ? 'true' : 'false'}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
||||
* 流式中默认收起:表头「正在思考 · 最新一行」持续刷新,不展示全部;
|
||||
* 手动展开时限高滚动预览——新内容在尾部并自动吸底,用户上滚即取消吸底
|
||||
* (滚回底部恢复)。完成后默认展开完整展示全部思考,表头「思考 · 首行」。
|
||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
||||
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由
|
||||
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。
|
||||
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||
* 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演;
|
||||
* 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。
|
||||
* 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成
|
||||
* 面板可见缩短。rows 由真实思考文本逐行喂入。 */
|
||||
export function Thinking({
|
||||
rows,
|
||||
streaming,
|
||||
@@ -17,32 +22,9 @@ export function Thinking({
|
||||
activeLabel?: string
|
||||
doneLabel?: string
|
||||
}): ReactNode {
|
||||
// 流式中默认收起,完成后默认展开;用户点击后以手动状态为准
|
||||
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||
const expanded = manualExpanded ?? !streaming
|
||||
|
||||
// 流式轨迹的行必须「随到随显」: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 expanded = manualExpanded ?? true
|
||||
|
||||
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
|
||||
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||
@@ -50,12 +32,12 @@ export function Thinking({
|
||||
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-95 flex-col">
|
||||
<div className="flex w-full flex-col">
|
||||
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{/* 展开轨迹:流式时限高滚动预览(吸底跟随),完成后完整展示全部思考 */}
|
||||
{/* 展开轨迹:不限高完整展示全部思考(收起由外层过程区承担) */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity]"
|
||||
style={{
|
||||
@@ -111,11 +93,7 @@ export function Thinking({
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className={`mt-1 ml-[5px] overflow-y-auto pl-4 ${streaming ? 'max-h-[220px]' : 'max-h-[400px]'}`}
|
||||
>
|
||||
<div className="mt-1 ml-[5px] pl-4">
|
||||
<div className="relative">
|
||||
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
@@ -123,11 +101,7 @@ export function Thinking({
|
||||
<div
|
||||
key={i}
|
||||
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
|
||||
style={{
|
||||
animation: streamedMountRef.current
|
||||
? 'fade-up 250ms ease'
|
||||
: `fade-up 400ms ease ${Math.min(i, 8) * 120}ms both`
|
||||
}}
|
||||
style={{ animation: 'fade-up 250ms ease' }}
|
||||
>
|
||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
|
||||
{row.primary}
|
||||
|
||||
@@ -32,6 +32,8 @@ interface BrowserPanelProps {
|
||||
onNewTab: (url?: string) => void
|
||||
onSelectTab: (tabId: string) => void
|
||||
onCloseTab: (tabId: string) => void
|
||||
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
||||
onCloseAllTabs: () => void
|
||||
onAcknowledgeRisk: () => void
|
||||
onDetectSources: () => Promise<BrowserSource[]>
|
||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
@@ -77,9 +79,54 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
}
|
||||
|
||||
const riskBanner = state && !state.riskAcknowledged
|
||||
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时隐藏(原生层级永远在 DOM 之上)
|
||||
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
||||
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 (
|
||||
<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>
|
||||
))}
|
||||
|
||||
{(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 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-zinc-100" />
|
||||
@@ -323,12 +384,16 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
)}
|
||||
|
||||
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
||||
* 暂停期(面板/页签菜单打开)保持纯白,不闪空态图标 */}
|
||||
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */}
|
||||
<div className="relative min-h-0 flex-1 bg-white">
|
||||
{showSlot && activeTab ? (
|
||||
{activeTab && !slotHidden ? (
|
||||
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
||||
) : 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">
|
||||
<LuGlobe size={28} className="text-zinc-300" />
|
||||
|
||||
@@ -12,7 +12,10 @@ import * as React from 'react'
|
||||
|
||||
let revisionCounter = 0
|
||||
function nextRevision(): number {
|
||||
revisionCounter += 1
|
||||
// 以 Date.now() 为底:修订号必须跨模块热替换/整页重载单调递增——主进程
|
||||
// 不随渲染层重载,各标签页还记着旧 lastLayoutRevision,纯自增计数器在
|
||||
// 重载后归零会被 revision <= last 拒绝,原生视图从此收不到显示指令(页面空白)
|
||||
revisionCounter = Math.max(revisionCounter + 1, Date.now())
|
||||
return revisionCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useBrowser(): {
|
||||
goBack: wrap(() => window.api.browserGoBack()),
|
||||
goForward: wrap(() => window.api.browserGoForward()),
|
||||
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)),
|
||||
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
||||
closeBrowser: wrap(() => window.api.browserClose()),
|
||||
|
||||
@@ -93,6 +93,7 @@ declare global {
|
||||
browserSelectTab: (tabId: string) => Promise<BrowserUiState>
|
||||
browserCloseTab: (tabId?: string) => Promise<BrowserUiState>
|
||||
browserClose: () => Promise<BrowserUiState>
|
||||
browserCaptureTab: (tabId?: string) => Promise<string>
|
||||
browserAcknowledgeRisk: () => Promise<void>
|
||||
browserSetLayout: (layout: {
|
||||
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;
|
||||
width: 0.5em;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
margin-left: 3px;
|
||||
vertical-align: -0.15em;
|
||||
background: var(--ink);
|
||||
border-radius: 1px;
|
||||
animation: caret-blink 0.9s steps(1) infinite;
|
||||
|
||||
Reference in New Issue
Block a user