feat(tasks): 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置

三件事一起落地(用户 brief),各自独立可验证:

1) 后台任务输出改为常驻浮动窗
   - 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
     复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
     (右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
     只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
     拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
   - 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
     setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
   - JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
     (原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
     .popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。

2) 节点卡统一
   - 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
     层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
     当前会话改为 2px accent 描边(「边框加粗高亮」)。
   - 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
   - 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
     1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
     (border-box + min-height,卡片外框 247×134 与预算逐像素一致)。

3) 手机设置
   - 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
     PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
   - 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
     任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
   - 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。

验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。

(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
This commit is contained in:
Menghuan1918
2026-09-27 22:18:57 +08:00
parent 5ea2f2d6ec
commit d38fefe55d
38 changed files with 1300 additions and 156 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* Chrome of the plugin's persistent floating window (FloatingWindow.tsx):
* a fixed, portaled card with a drag handle title bar, a scrolling body, an
* optional footer and three resize strips.
*
* Tokens only (`--dsw-alias-*` / `--dsw-font-*`): the frame wears the same
* 1px `border-l2` edge and 8px radius as the Tasks page's node cards, and the
* floating layer keeps the app's `--dsw-shadow-lv3` (the one shadow tier the
* plugin is allowed on a floating surface).
*/
.window {
position: fixed;
z-index: 90;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
box-shadow: var(--dsw-shadow-lv3);
overflow: hidden;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
box-sizing: border-box;
}
/* The drag handle. It is exactly one control tall, so the close button and the
title sit on one optical line whatever the header's actions are. */
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: none;
min-height: 34px;
padding: 0 6px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
cursor: grab;
user-select: none;
}
.titleBar:active {
cursor: grabbing;
}
.title {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--ds-font-family-code, monospace);
font-size: 11px;
color: var(--dsw-alias-label-secondary);
}
.titleActions {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.close {
flex: none;
}
/* The body IS the height bound: whatever the caller renders (a long terminal
log, a table) scrolls here instead of growing the window past the viewport.
`min-height: 0` is what lets a flex child actually shrink and scroll. */
.body {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 10px 12px 12px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: none;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-base);
}
/* Resize strips sit on top of the body's own scrollbar area; each one owns
only its axis (the cursor says which). */
.handle {
position: absolute;
touch-action: none;
}
.handleRight {
top: 34px;
right: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
}
.handleBottom {
left: 0;
right: 0;
bottom: 0;
height: 6px;
cursor: ns-resize;
}
/* The corner wins over both edges (it is declared last and larger). */
.handleCorner {
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(
135deg,
transparent 0%,
transparent 55%,
color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent) 55%,
color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent) 100%
);
}
+346
View File
@@ -0,0 +1,346 @@
/**
* The plugin's reusable PERSISTENT floating window: a draggable, resizable,
* body-portaled card with its own title bar, close button and scrolling body.
*
* Why a second window primitive next to {@link AnchoredPopover}: that one is a
* POPOVER — it is anchored to an element, sized to its content, and dismissed
* by any interaction outside it (outside mousedown, window blur, its anchor
* leaving the viewport). That contract is exactly wrong for a surface the
* reader WATCHES for minutes: a job's output must survive clicking elsewhere,
* must not grow past the viewport, and must keep its place while the work
* runs. This component therefore carries the opposite lifecycle — only the
* close button and Escape end it — plus the two interactions a watched pane
* needs: move it out of the way, and resize it to the log at hand.
*
* The body is the window's own scroll container, which is what bounds a long
* output (the popover it replaces had no height limit at all, so a chatty job
* simply ran off screen). Callers that follow a live tail pass `bodyRef` and
* scroll it themselves.
*
* Mechanics reuse the two proven patterns of this codebase rather than
* inventing a third: the MOVE rides window-level pointer listeners (the
* `AnchoredPopover` drag — capture on the container would retarget the
* derived click and kill the buttons inside), and the RESIZE rides
* `setPointerCapture` on the handle plus a `createFrameBatcher` per-frame
* commit (the `EditorHost` docked-panel resize; a per-event setState on a
* streamed log is the visible drag lag of #315).
*
* Skin contract: tokens only (`--dsw-alias-*` / `--dsw-font-*`), the floating
* layer keeps the app's `--dsw-shadow-lv3`, and the radius matches the Tasks
* page's node cards (8px).
*/
import {
useCallback, useEffect, useMemo, useRef, useState,
type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject,
} from 'react'
import { createPortal } from 'react-dom'
import { Button, IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { createFrameBatcher } from './frame-batcher.ts'
import { t } from './locales.ts'
import css from './FloatingWindow.module.css'
/** Default share of the viewport a fresh window takes. */
const DEFAULT_WIDTH_RATIO = 0.7
const DEFAULT_HEIGHT_RATIO = 0.6
/** Default floor, and the hard floor a resize can never cross. */
const DEFAULT_MIN_WIDTH = 320
const DEFAULT_MIN_HEIGHT = 240
/** How much of the title bar must stay on screen while dragging. */
const KEEP_ON_SCREEN = 48
/** Free space kept at the viewport edges. */
const VIEWPORT_MARGIN = 8
/** Room a window never takes from the viewport (title bar + margins). */
const MIN_VIEWPORT_HEIGHT = 160
/** One window box, viewport coordinates. */
interface WindowBox {
left: number
top: number
width: number
height: number
}
export interface FloatingWindowProps {
/** Title-bar text (also the dialog's accessible name). */
title: string
/** Close (the button, or Escape). */
onClose(): void
/** Initial size; omitted → 70% × 60% of the viewport, then clamped. */
initialSize?: { width: number; height: number }
/** Resize floor; omitted → 320 × 240. */
minSize?: { width: number; height: number }
/**
* Where the window first appears: just below-right of this element (the
* row that opened it). Omitted / off-screen anchors centre it.
*/
anchor?: HTMLElement | null
/** Header controls, left of the close button (a job's copy action). */
actions?: ReactNode
/** Bottom action row (a job's follow switch + stop control). */
footer?: ReactNode
/**
* The scrollable body element, for callers that follow a live tail. The
* caller scrolls it; the window owns its size and its overflow.
*/
bodyRef?: RefObject<HTMLDivElement>
children: ReactNode
}
/** Clamp one number into `[min, max]` (max first, so an inverted range is safe). */
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(min, value), Math.max(min, max))
}
/**
* Clamp one axis of a window's origin. The rule is "keep the WHOLE window on
* screen when it fits, and otherwise keep {@link KEEP_ON_SCREEN}px of it (its
* title bar / its right edge) reachable": a window that hangs off the bottom
* edge hides its own resize corner, which is exactly how the first real-host
* check lost it.
*/
function clampOrigin(start: number, size: number, viewport: number): number {
const fullyInside = viewport - size - VIEWPORT_MARGIN
return fullyInside >= VIEWPORT_MARGIN
? clamp(start, VIEWPORT_MARGIN, fullyInside)
: clamp(start, KEEP_ON_SCREEN - size, viewport - KEEP_ON_SCREEN)
}
/** The viewport-clamped initial box of one window. */
function initialBox(
anchor: HTMLElement | null | undefined,
size: { width: number; height: number } | undefined,
min: { width: number; height: number },
viewport: { width: number; height: number },
): WindowBox {
const width = clamp(
size?.width ?? viewport.width * DEFAULT_WIDTH_RATIO,
min.width,
Math.max(min.width, viewport.width - VIEWPORT_MARGIN * 2),
)
const height = clamp(
size?.height ?? viewport.height * DEFAULT_HEIGHT_RATIO,
min.height,
Math.max(min.height, viewport.height - VIEWPORT_MARGIN * 2),
)
const rect = anchor?.getBoundingClientRect()
const left = rect === undefined
? (viewport.width - width) / 2
: clamp(rect.left, VIEWPORT_MARGIN, viewport.width - width - VIEWPORT_MARGIN)
const top = rect === undefined
? Math.max(VIEWPORT_MARGIN, (viewport.height - height) / 2)
: clamp(rect.bottom + 6, VIEWPORT_MARGIN, viewport.height - height - VIEWPORT_MARGIN)
return {
left: Math.round(left),
top: Math.round(top),
width: Math.round(width),
height: Math.round(height),
}
}
/**
* Render a persistent floating window. The caller owns WHAT is shown (body,
* header actions, footer); this component owns the frame, the geometry, the
* two drag gestures and the dismissal contract (close button + Escape only).
*/
export function FloatingWindow(props: FloatingWindowProps): ReactNode {
const { title, onClose, initialSize, minSize, anchor, actions, footer, bodyRef, children } = props
const min = useMemo(() => ({
width: minSize?.width ?? DEFAULT_MIN_WIDTH,
height: minSize?.height ?? DEFAULT_MIN_HEIGHT,
}), [minSize?.width, minSize?.height])
const frameRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
/**
* The box is measured ONCE, on mount, from the live viewport: a later
* re-render (a streamed output frame, a status flip) must never move or
* resize a window the reader placed.
*/
const [box, setBox] = useState<WindowBox | null>(null)
const boxRef = useRef(box)
boxRef.current = box
useEffect(() => {
setBox(initialBox(anchor, initialSize, min, {
width: window.innerWidth,
height: window.innerHeight,
}))
// Mount-only on purpose: see the comment above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/** Keep the box reachable after a viewport change (a rotation, a split pane). */
useEffect(() => {
const onResize = (): void => {
const current = boxRef.current
if (current === null) return
const width = clamp(current.width, min.width, Math.max(min.width, window.innerWidth - VIEWPORT_MARGIN * 2))
const height = clamp(
current.height,
min.height,
Math.max(min.height, window.innerHeight - MIN_VIEWPORT_HEIGHT),
)
setBox({
width,
height,
left: clampOrigin(current.left, width, window.innerWidth),
top: clampOrigin(current.top, height, window.innerHeight),
})
}
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize) }
}, [min.width, min.height])
// Escape only: no outside-click, no blur, no anchor observer (see the header).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', onKeyDown, true)
return () => { document.removeEventListener('keydown', onKeyDown, true) }
}, [])
/** Drag the window by its title bar (buttons inside it keep working). */
const onTitlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return
const target = event.target as HTMLElement
// The header's own controls (copy, close) must not start a drag.
if (target.closest('button, input, textarea, select, a, [data-window-no-drag]') !== null) return
const start = boxRef.current
if (start === null) return
event.preventDefault()
const startX = event.clientX
const startY = event.clientY
const onMove = (move: PointerEvent): void => {
setBox({
...start,
left: clampOrigin(start.left + (move.clientX - startX), start.width, window.innerWidth),
top: clampOrigin(start.top + (move.clientY - startY), start.height, window.innerHeight),
})
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
/** Resize from one edge handle: `axis` picks which dimension the handle owns. */
const resizeBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => { resizeBatcher.dispose() }, [resizeBatcher])
const resizeRef = useRef<{ startX: number; startY: number; box: WindowBox; axis: 'x' | 'y' | 'both' } | null>(null)
const pendingBoxRef = useRef<WindowBox | null>(null)
const onResizeStart = useCallback((axis: 'x' | 'y' | 'both') => (event: ReactPointerEvent<HTMLDivElement>): void => {
const start = boxRef.current
if (start === null || event.button !== 0) return
event.preventDefault()
event.stopPropagation()
// jsdom lacks setPointerCapture — the tests dispatch plain pointer events.
event.currentTarget.setPointerCapture?.(event.pointerId)
resizeRef.current = { startX: event.clientX, startY: event.clientY, box: start, axis }
}, [])
const onResizeMove = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
const drag = resizeRef.current
if (drag === null) return
const growX = drag.axis === 'y' ? 0 : event.clientX - drag.startX
const growY = drag.axis === 'x' ? 0 : event.clientY - drag.startY
pendingBoxRef.current = {
...drag.box,
width: clamp(
drag.box.width + growX,
min.width,
Math.max(min.width, window.innerWidth - drag.box.left - VIEWPORT_MARGIN),
),
height: clamp(
drag.box.height + growY,
min.height,
Math.max(min.height, window.innerHeight - drag.box.top - VIEWPORT_MARGIN),
),
}
resizeBatcher.schedule(() => {
const pending = pendingBoxRef.current
if (pending !== null) setBox(pending)
})
}, [min.width, min.height, resizeBatcher])
const onResizeEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (resizeRef.current === null) return
// Flush the last frame: a release can land with the final move still
// queued (the committed box would otherwise lose it).
resizeBatcher.flushNow()
resizeRef.current = null
pendingBoxRef.current = null
event.currentTarget.releasePointerCapture?.(event.pointerId)
}, [resizeBatcher])
return createPortal(
<div
ref={frameRef}
role="dialog"
aria-label={title}
data-floating-window
className={css.window}
style={box === null
? { left: -9999, top: -9999, width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT }
: { left: box.left, top: box.top, width: box.width, height: box.height }}
>
<div className={css.titleBar} data-window-handle onPointerDown={onTitlePointerDown}>
<span className={css.title} title={title}>{title}</span>
<span className={css.titleActions} data-window-no-drag>
{actions}
<Button
variant="ghost"
size="sm"
className={css.close}
icon={<IconCloseFillRegular size={14} />}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
/>
</span>
</div>
<div ref={bodyRef} className={css.body} data-window-body>
{children}
</div>
{footer !== undefined && <div className={css.footer} data-window-footer>{footer}</div>}
{/*
Three handles rather than eight: the window grows from the bottom-right
in practice (a log gets longer), and the two edges keep a single-axis
resize possible. They sit above the body's scrollbar as thin strips.
*/}
<div
className={`${css.handle} ${css.handleRight}`}
data-window-resize="x"
aria-hidden="true"
onPointerDown={onResizeStart('x')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<div
className={`${css.handle} ${css.handleBottom}`}
data-window-resize="y"
aria-hidden="true"
onPointerDown={onResizeStart('y')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<div
className={`${css.handle} ${css.handleCorner}`}
data-window-resize="both"
aria-hidden="true"
onPointerDown={onResizeStart('both')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
</div>,
document.body,
)
}
+74 -58
View File
@@ -5,11 +5,15 @@
* service — see jobs-client.ts) collapse into a bottom bar that AUTO-COLLAPSES
* once the tree has many agents — the manual toggle always wins afterwards.
*
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
* AnchoredPopover) instead of docking a pane inside the page. It shows the
* output the host streams for an OBSERVER (never the model's consuming
* cursor), with a copy action, a follow-latest switch, the two-click kill, and
* a terminal-style tail while the job runs.
* Window: clicking a row opens a PERSISTENT floating window (FloatingWindow —
* draggable, resizable, closed only by its own button or Escape), because a
* job's output is something the reader watches for minutes: the popover it
* used to open was dismissed by any click elsewhere and had no height bound,
* so a chatty job ran off the bottom of the screen. The window's BODY is the
* scroll container, so the output stays readable whatever its length. It shows
* what the host streams for an OBSERVER (never the model's consuming cursor),
* with a copy action in the title bar, a follow-latest switch and the
* two-click kill in the footer, and a terminal-style tail while the job runs.
*
* Every control is a host primitive (Button / Tag / Switch / StateDot /
* TerminalBlock).
@@ -28,6 +32,7 @@ import {
jobStatusLabel,
type TreeJob,
} from './subagent-jobs.ts'
import { FloatingWindow } from './FloatingWindow.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -202,13 +207,13 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
}
/**
* The output popover content of one job: the text the MODEL has read so far
* (replayed from the owner session's event log), refreshed every
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
* can copy the output and turn the terminal-style tail off; the kill action
* keeps its two-click confirm.
* The output WINDOW of one job: what the host streams for an observer of this
* job, inside the plugin's persistent floating window. The window owns the
* frame, the geometry and the dismissal contract; this component owns the
* content (status header rows, the terminal tail) and the footer controls
* (follow-latest, two-click kill).
*/
export function JobOutputPopoverContent(props: {
export function JobOutputWindow(props: {
/** The host client jobs service (absent → the panel only reports that). */
jobs: SidebarClientJobsService | undefined
job: SidebarJobView
@@ -216,14 +221,18 @@ export function JobOutputPopoverContent(props: {
ownerSessionId: string
/** The host's streamed observation of THIS job (undefined until it arrives). */
observed: SidebarObservedJob | undefined
/** The row's main button — where the window first appears. */
anchor?: HTMLElement | null
/** Close the window (the close button, or Escape). */
onClose(): void
}): ReactNode {
const { jobs, job, ownerSessionId, observed } = props
const { jobs, job, ownerSessionId, observed, anchor, onClose } = props
const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false)
const [armed, setArmed] = useState(false)
const [killing, setKilling] = useState(false)
const [killFailed, setKillFailed] = useState(false)
const preRef = useRef<HTMLPreElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const live = isJobLive(job)
const text = observed?.text ?? ''
const labels = useMemo(() => terminalBlockLabels(), [])
@@ -235,15 +244,16 @@ export function JobOutputPopoverContent(props: {
return () => { window.clearTimeout(timer) }
}, [armed])
// Terminal-tail behavior: every frame pins the view to the newest output
// while the reader keeps the follow switch on.
// Terminal-tail behavior: every frame pins the BODY (the window's own scroll
// container — the element the previous popover wrapped in a div that never
// scrolled) to the newest output while the reader keeps follow on.
useEffect(() => {
if (!live || !follow || text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
const body = bodyRef.current
if (body !== null) body.scrollTop = body.scrollHeight
}, [text, live, follow])
/** Two-click kill from the popover (the roster settles the row afterwards). */
/** Two-click kill from the window (the roster settles the row afterwards). */
const kill = async (): Promise<void> => {
if (jobs === undefined) return
setKilling(true)
@@ -259,10 +269,13 @@ export function JobOutputPopoverContent(props: {
}
return (
<div className={css.popCard} data-popover-handle>
<div className={css.popHead}>
<span>{t('jobs')}</span>
<span className={css.popHeadActions} data-popover-no-drag>
<FloatingWindow
title={job.label}
onClose={onClose}
{...(anchor === undefined ? {} : { anchor })}
bodyRef={bodyRef}
actions={(
<>
<Button
variant="ghost"
size="sm"
@@ -277,8 +290,43 @@ export function JobOutputPopoverContent(props: {
})
}}
/>
</span>
</div>
</>
)}
footer={(
<div className={css.jobPopActions}>
{/*
The host Switch draws a bare track, so its wording rides beside it —
the same `jobFollowTail` copy the switch already carries as its
accessible name.
*/}
<span className={css.jobPopFollow}>
<Switch
checked={follow}
onChange={setFollow}
label={t('jobFollowTail')}
disabled={!live}
/>
<span className={css.jobPopFollowLabel}>{t('jobFollowTail')}</span>
</span>
{live && (
<Button
variant="outline"
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
icon={<IconStopFillRegular size={11} />}
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
</div>
)}
>
<div className={css.jobPopTitle}>
<StateDot state={jobDotState(job.status)} size={6} />
<span className={css.popTitle} title={job.label}>{job.label}</span>
@@ -296,7 +344,7 @@ export function JobOutputPopoverContent(props: {
{observed.error !== undefined && <div className={css.popError}>{t('jobOutputError')}</div>}
{observed.text.length > 0
? (
<div ref={preRef as unknown as React.RefObject<HTMLDivElement>} data-popover-no-drag>
<div>
<TerminalBlock
command={job.label}
output={observed.text}
@@ -311,38 +359,6 @@ export function JobOutputPopoverContent(props: {
: <div className={css.popHint}>{t('jobNoOutput')}</div>}
</>
)}
<div className={css.jobPopActions} data-popover-no-drag>
{/*
The host Switch draws a bare track, so its wording rides beside it —
the same `jobFollowTail` copy the switch already carries as its
accessible name.
*/}
<span className={css.jobPopFollow}>
<Switch
checked={follow}
onChange={setFollow}
label={t('jobFollowTail')}
disabled={!live}
/>
<span className={css.jobPopFollowLabel}>{t('jobFollowTail')}</span>
</span>
{live && (
<Button
variant="outline"
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
icon={<IconStopFillRegular size={11} />}
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
</div>
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
</div>
</FloatingWindow>
)
}
+30
View File
@@ -900,6 +900,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</div>
</div>
{/* 手机: the narrow-viewport adaptations. Both switches only ever change
what happens on a NARROW viewport (the plugin's own 768px bracket),
so a desktop reader's behaviour is untouched whatever they pick —
and neither one rewrites the wide-viewport switches above. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsMobileTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsMobileNoAutoOpenTitle')}</span>
<span className={css.desc}>{t('settingsMobileNoAutoOpenDesc')}</span>
</span>
<Switch
label={t('settingsMobileNoAutoOpenTitle')}
checked={prefs.mobileNoAutoOpen}
onChange={(next) => { applyPref({ mobileNoAutoOpen: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsMobileTreeTitle')}</span>
<span className={css.desc}>{t('settingsMobileTreeDesc')}</span>
</span>
<Switch
label={t('settingsMobileTreeTitle')}
checked={prefs.mobileDefaultTree}
onChange={(next) => { applyPref({ mobileDefaultTree: next }) }}
/>
</div>
</div>
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */}
+27 -15
View File
@@ -50,6 +50,7 @@ import { treeSessionIds } from './subagent-lineage.ts'
import { orderJobs, type TreeJob } from './subagent-jobs.ts'
import { api, type TeamsViewResult } from './api.ts'
import { usePolling } from './use-polling.ts'
import { useNarrowViewport } from './breakpoints.ts'
import {
clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot,
} from './jobs-client.ts'
@@ -57,7 +58,7 @@ import { t } from './locales.ts'
import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts'
import { TasksGraph } from './TasksGraph.tsx'
import { TasksTree } from './TasksTree.tsx'
import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
import { JobsDrawer, JobOutputWindow } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
import { TeamBoard } from './TeamBoard.tsx'
@@ -240,10 +241,17 @@ export function SubagentView(props: {
)
// The default view mode comes from the side card prefs (settings select);
// the in-page toggle overrides it ephemerally.
// the in-page toggle overrides it ephemerally. On a NARROW viewport the
// `mobileDefaultTree` preference swaps that default to the classic tree —
// only the default: the toggle below still flips this session's page.
const narrow = useNarrowViewport()
const prefsMode = useSyncExternalStore(
useMemo(() => (callback: () => void) => store?.subscribe(callback) ?? (() => {}), [store]),
useCallback(() => store?.getPrefs().tasksViewMode ?? 'graph', [store]),
useCallback((): 'graph' | 'tree' => {
const prefs = store?.getPrefs()
if (narrow && prefs?.mobileDefaultTree === true) return 'tree'
return prefs?.tasksViewMode ?? 'graph'
}, [store, narrow]),
)
const [modeOverride, setModeOverride] = useState<'graph' | 'tree' | undefined>(undefined)
const mode = modeOverride ?? prefsMode
@@ -453,11 +461,13 @@ export function SubagentView(props: {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
return (
<JobOutputPopoverContent
<JobOutputWindow
jobs={treeJobs.jobs}
ownerSessionId={row.ownerSessionId}
job={row.job}
observed={treeJobs.observed[row.job.id]}
anchor={popover.anchor}
onClose={closePopover}
/>
)
}
@@ -599,18 +609,20 @@ export function SubagentView(props: {
: { kind: 'job', jobId: row.job.id, anchor })
}}
/>
{popover?.kind === 'task'
{/*
Two surfaces, two lifecycles: the job output is a PERSISTENT window
(its own frame, closed by its button/Escape only), while node and
workflow details stay anchored popovers on the 280px card.
*/}
{popover?.kind === 'job'
? popoverContent
: (
<AnchoredPopover
anchor={popover?.anchor ?? null}
onClose={closePopover}
draggable={popover?.kind === 'job'}
width={popover?.kind === 'job' ? 380 : 280}
>
{popoverContent}
</AnchoredPopover>
)}
: popover?.kind === 'task'
? popoverContent
: (
<AnchoredPopover anchor={popover?.anchor ?? null} onClose={closePopover} width={280}>
{popoverContent}
</AnchoredPopover>
)}
</div>
)
}
+5
View File
@@ -253,6 +253,11 @@ export const ar: Record<string, string> = {
settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)',
settingsTabsTitle: 'محتوى الشريط الجانبي',
settingsViewersTitle: 'عارضات الملفات',
settingsMobileTitle: "الجوال",
settingsMobileNoAutoOpenTitle: "عدم الفتح التلقائي على الشاشات الضيقة",
settingsMobileNoAutoOpenDesc: "على الهاتف أو الشاشة الضيقة، لن يؤدي وكيل فرعي أو مهمة خلفية جديدة إلى الانتقال تلقائيًا إلى صفحة المهام (يمكنك فتحها يدويًا)",
settingsMobileTreeTitle: "افتراض الشجرة لصفحة المهام",
settingsMobileTreeDesc: "على الهاتف أو الشاشة الضيقة تُفتح صفحة المهام كشجرة كلاسيكية بدل مخطط سير العمل (مفتاح الصفحة يظل يعمل)",
settingsGeneralTitle: 'عام',
settingsPopup: 'إعدادات الميزة',
settingsViewerCatchAll: 'شامل: أي ملف',
+5
View File
@@ -238,6 +238,11 @@ export const de: Record<string, string> = {
settingsShellArgsPlaceholder: 'z. B. -l (leer = Standardargumente)',
settingsTabsTitle: 'Seitenleisten-Inhalt',
settingsViewersTitle: 'Datei-Viewer',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Auf schmalen Bildschirmen nicht automatisch öffnen",
settingsMobileNoAutoOpenDesc: "Auf dem Handy / schmalen Viewport wechselt ein neuer Subagent oder Hintergrundjob nicht mehr von selbst zur Aufgabenseite (manuell weiterhin möglich)",
settingsMobileTreeTitle: "Aufgabenseite standardmäßig als Baum",
settingsMobileTreeDesc: "Auf dem Handy / schmalen Viewport öffnet die Aufgabenseite als klassischer Baum statt als Workflow-Diagramm; der Umschalter auf der Seite bleibt",
settingsGeneralTitle: 'Allgemein',
settingsPopup: 'Funktionseinstellungen',
settingsViewerCatchAll: 'Catch-all: beliebige Datei',
+5
View File
@@ -245,6 +245,11 @@ export const fr: Record<string, string> = {
settingsShellArgsPlaceholder: 'ex. -l (vide = arguments par défaut)',
settingsTabsTitle: 'Contenu de la barre latérale',
settingsViewersTitle: 'Aperçus de fichiers',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Ne pas ouvrir automatiquement sur écran étroit",
settingsMobileNoAutoOpenDesc: "Sur téléphone / écran étroit, un nouveau sous-agent ou une tâche d'arrière-plan ne bascule plus tout seul vers la page Tâches (ouverture manuelle toujours possible)",
settingsMobileTreeTitle: "Page Tâches en arbre par défaut",
settingsMobileTreeDesc: "Sur téléphone / écran étroit, la page Tâches s'ouvre en arbre classique plutôt qu'en graphe de workflow ; le bouton de la page reste actif",
settingsGeneralTitle: 'Général',
settingsPopup: 'Réglages de la fonctionnalité',
settingsViewerCatchAll: 'Par défaut : n’importe quel fichier',
+5
View File
@@ -252,6 +252,11 @@ export const hi: Record<string, string> = {
settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)',
settingsTabsTitle: 'साइडबार सामग्री',
settingsViewersTitle: 'फ़ाइल दर्शक',
settingsMobileTitle: "मोबाइल",
settingsMobileNoAutoOpenTitle: "छोटी स्क्रीन पर स्वतः न खोलें",
settingsMobileNoAutoOpenDesc: "फ़ोन / छोटी स्क्रीन पर नया सबएजेंट या पृष्ठभूमि कार्य अपने आप कार्य पृष्ठ नहीं खोलेगा (मैन्युअल रूप से खोल सकते हैं)",
settingsMobileTreeTitle: "कार्य पृष्ठ डिफ़ॉल्ट रूप से ट्री",
settingsMobileTreeDesc: "फ़ोन / छोटी स्क्रीन पर कार्य पृष्ठ वर्कफ़्लो ग्राफ़ की जगह क्लासिक ट्री में खुलता है; पृष्ठ का टॉगल काम करता रहता है",
settingsGeneralTitle: 'सामान्य',
settingsPopup: 'सुविधा सेटिंग्स',
settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल',
+5
View File
@@ -250,6 +250,11 @@ export const id: Record<string, string> = {
settingsShellArgsPlaceholder: 'mis. -l (kosong = default)',
settingsTabsTitle: 'Konten sidebar',
settingsViewersTitle: 'Pratinjau berkas',
settingsMobileTitle: "Seluler",
settingsMobileNoAutoOpenTitle: "Jangan buka otomatis di layar sempit",
settingsMobileNoAutoOpenDesc: "Di ponsel / layar sempit, subagen atau tugas latar baru tidak lagi berpindah sendiri ke halaman Tugas (masih bisa dibuka manual)",
settingsMobileTreeTitle: "Halaman Tugas default pohon",
settingsMobileTreeDesc: "Di ponsel / layar sempit halaman Tugas terbuka sebagai pohon klasik, bukan graf alur kerja; tombol di halaman tetap berfungsi",
settingsGeneralTitle: 'Umum',
settingsPopup: 'Pengaturan fitur',
settingsViewerCatchAll: 'Catch-all: berkas apa pun',
+5
View File
@@ -243,6 +243,11 @@ export const it: Record<string, string> = {
settingsShellArgsPlaceholder: 'es. -l (vuoto = predefiniti)',
settingsTabsTitle: 'Contenuto della barra laterale',
settingsViewersTitle: 'Visualizzatori di file',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Non aprire automaticamente su schermi stretti",
settingsMobileNoAutoOpenDesc: "Su telefono / schermo stretto un nuovo subagent o job in background non passa più da solo alla pagina Attività (puoi comunque aprirla a mano)",
settingsMobileTreeTitle: "Pagina Attività predefinita ad albero",
settingsMobileTreeDesc: "Su telefono / schermo stretto la pagina Attività si apre come albero classico invece che come grafo del workflow; il pulsante nella pagina resta attivo",
settingsGeneralTitle: 'Generali',
settingsPopup: 'Impostazioni della funzionalità',
settingsViewerCatchAll: 'Raccoglitore: qualsiasi file',
+5
View File
@@ -252,6 +252,11 @@ export const ja: Record<string, string> = {
settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)',
settingsTabsTitle: 'サイドバーの内容',
settingsViewersTitle: 'ファイルプレビュー',
settingsMobileTitle: "モバイル",
settingsMobileNoAutoOpenTitle: "狭い画面では自動で開かない",
settingsMobileNoAutoOpenDesc: "スマホ / 狭い画面では、新しいサブエージェントやバックグラウンドタスクがあってもタスクページへ自動で切り替わりません(手動では開けます)",
settingsMobileTreeTitle: "タスクページはツリーを既定にする",
settingsMobileTreeDesc: "スマホ / 狭い画面ではタスクページをワークフロー図ではなくツリーで開きます(ページ内の切り替えはそのまま使えます)",
settingsGeneralTitle: '一般',
settingsPopup: '機能設定',
settingsViewerCatchAll: 'フォールバック:任意のファイル',
+5
View File
@@ -244,6 +244,11 @@ export const ko: Record<string, string> = {
settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)',
settingsTabsTitle: '사이드바 콘텐츠',
settingsViewersTitle: '파일 미리보기',
settingsMobileTitle: "모바일",
settingsMobileNoAutoOpenTitle: "좁은 화면에서는 자동으로 열지 않기",
settingsMobileNoAutoOpenDesc: "휴대폰 / 좁은 화면에서는 새 하위 에이전트나 백그라운드 작업이 있어도 작업 페이지로 자동 전환되지 않습니다(직접 열 수는 있습니다)",
settingsMobileTreeTitle: "작업 페이지 기본을 트리로",
settingsMobileTreeDesc: "휴대폰 / 좁은 화면에서 작업 페이지가 워크플로 그래프 대신 클래식 트리로 열립니다. 페이지 내 전환은 그대로 동작합니다",
settingsGeneralTitle: '일반',
settingsPopup: '기능 설정',
settingsViewerCatchAll: '폴백: 모든 파일',
+5
View File
@@ -250,6 +250,11 @@ export const nl: Record<string, string> = {
settingsShellArgsPlaceholder: 'bijv. -l (leeg = standaardwaarden)',
settingsTabsTitle: 'Zijbalk-inhoud',
settingsViewersTitle: 'Bestandsviewers',
settingsMobileTitle: "Mobiel",
settingsMobileNoAutoOpenTitle: "Niet automatisch openen op smalle schermen",
settingsMobileNoAutoOpenDesc: "Op telefoon / smalle viewport schakelt een nieuwe subagent of achtergrondtaak niet meer vanzelf naar de Takenpagina (handmatig kan nog)",
settingsMobileTreeTitle: "Takenpagina standaard als boom",
settingsMobileTreeDesc: "Op telefoon / smalle viewport opent de Takenpagina als klassieke boom in plaats van werkstroomdiagram; de schakelaar op de pagina blijft werken",
settingsGeneralTitle: 'Algemeen',
settingsPopup: 'Functie-instellingen',
settingsViewerCatchAll: 'Catch-all: elk bestand',
+5
View File
@@ -254,6 +254,11 @@ export const pl: Record<string, string> = {
settingsShellArgsPlaceholder: 'np. -l (puste = domyślne)',
settingsTabsTitle: 'Treść panelu bocznego',
settingsViewersTitle: 'Podglądy plików',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Nie otwieraj automatycznie na wąskim ekranie",
settingsMobileNoAutoOpenDesc: "Na telefonie / wąskim ekranie nowy podagent lub zadanie w tle nie przełącza już samoczynnie na stronę Zadań (nadal można otworzyć ręcznie)",
settingsMobileTreeTitle: "Strona Zadań domyślnie jako drzewo",
settingsMobileTreeDesc: "Na telefonie / wąskim ekranie strona Zadań otwiera się jako klasyczne drzewo, a nie graf przepływu; przełącznik na stronie nadal działa",
settingsGeneralTitle: 'Ogólne',
settingsPopup: 'Ustawienia funkcji',
settingsViewerCatchAll: 'Zapasowy: dowolny plik',
+5
View File
@@ -235,6 +235,11 @@ export const pt: Record<string, string> = {
settingsShellArgsPlaceholder: 'ex.: -l (vazio = padrões)',
settingsTabsTitle: 'Conteúdo da barra lateral',
settingsViewersTitle: 'Visualizadores de arquivos',
settingsMobileTitle: "Celular",
settingsMobileNoAutoOpenTitle: "Não abrir automaticamente em telas estreitas",
settingsMobileNoAutoOpenDesc: "No celular / tela estreita, um novo subagente ou tarefa em segundo plano não muda mais sozinho para a página Tarefas (ainda dá para abrir manualmente)",
settingsMobileTreeTitle: "Página Tarefas em árvore por padrão",
settingsMobileTreeDesc: "No celular / tela estreita a página Tarefas abre como árvore clássica em vez de grafo de fluxo; o botão da página continua funcionando",
settingsGeneralTitle: 'Geral',
settingsPopup: 'Configurações do recurso',
settingsViewerCatchAll: 'Curinga: qualquer arquivo',
+5
View File
@@ -249,6 +249,11 @@ export const ru: Record<string, string> = {
settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)',
settingsTabsTitle: 'Содержимое боковой панели',
settingsViewersTitle: 'Просмотрщики файлов',
settingsMobileTitle: "Мобильный",
settingsMobileNoAutoOpenTitle: "Не открывать автоматически на узком экране",
settingsMobileNoAutoOpenDesc: "На телефоне / узком экране новый субагент или фоновая задача больше не переключает на страницу задач само (открыть вручную можно)",
settingsMobileTreeTitle: "Страница задач по умолчанию — дерево",
settingsMobileTreeDesc: "На телефоне / узком экране страница задач открывается классическим деревом, а не графом процесса; переключатель на странице продолжает работать",
settingsGeneralTitle: 'Общие',
settingsPopup: 'Настройки функции',
settingsViewerCatchAll: 'Универсальный: любой файл',
+5
View File
@@ -235,6 +235,11 @@ export const sv: Record<string, string> = {
settingsShellArgsPlaceholder: 't.ex. -l (tomt = standard)',
settingsTabsTitle: 'Sidopanelens innehåll',
settingsViewersTitle: 'Filvisare',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Öppna inte automatiskt på smala skärmar",
settingsMobileNoAutoOpenDesc: "På telefon / smal vyport växlar en ny subagent eller bakgrundsjobb inte längre själv till Uppgiftssidan (går fortfarande att öppna manuellt)",
settingsMobileTreeTitle: "Uppgiftssidan som träd som standard",
settingsMobileTreeDesc: "På telefon / smal vyport öppnas Uppgiftssidan som klassiskt träd i stället för arbetsflödesgraf; knappen på sidan fungerar kvar",
settingsGeneralTitle: 'Allmänt',
settingsPopup: 'Funktionsinställningar',
settingsViewerCatchAll: 'Fånga alla: alla filer',
+5
View File
@@ -252,6 +252,11 @@ export const th: Record<string, string> = {
settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)',
settingsTabsTitle: 'เนื้อหาแถบด้านข้าง',
settingsViewersTitle: 'ตัวพรีวิวไฟล์',
settingsMobileTitle: "มือถือ",
settingsMobileNoAutoOpenTitle: "ไม่เปิดอัตโนมัติบนจอแคบ",
settingsMobileNoAutoOpenDesc: "บนมือถือ / จอแคบ เอเจนต์ย่อยหรืองานเบื้องหลังใหม่จะไม่สลับไปหน้าภารกิจให้เองอีก (ยังเปิดเองได้)",
settingsMobileTreeTitle: "หน้าภารกิจใช้ทรีเป็นค่าเริ่มต้น",
settingsMobileTreeDesc: "บนมือถือ / จอแคบ หน้าภารกิจจะเปิดเป็นทรีแบบคลาสสิกแทนกราฟเวิร์กโฟลว์ ปุ่มสลับในหน้ายังใช้ได้",
settingsGeneralTitle: 'ทั่วไป',
settingsPopup: 'การตั้งค่าคุณสมบัติ',
settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ',
+5
View File
@@ -252,6 +252,11 @@ export const tr: Record<string, string> = {
settingsShellArgsPlaceholder: 'ör. -l (boş = varsayılanlar)',
settingsTabsTitle: 'Kenar çubuğu içeriği',
settingsViewersTitle: 'Dosya önizleyicileri',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Dar ekranlarda otomatik açma",
settingsMobileNoAutoOpenDesc: "Telefonda / dar görünümde yeni bir alt ajan veya arka plan görevi artık kendiliğinden Görevler sayfasına geçmez (elle açılabilir)",
settingsMobileTreeTitle: "Görevler sayfası varsayılan olarak ağaç",
settingsMobileTreeDesc: "Telefonda / dar görünümde Görevler sayfası iş akışı grafiği yerine klasik ağaç olarak açılır; sayfadaki düğme çalışmaya devam eder",
settingsGeneralTitle: 'Genel',
settingsPopup: 'Özellik ayarları',
settingsViewerCatchAll: 'Hepsi yakala: herhangi bir dosya',
+5
View File
@@ -252,6 +252,11 @@ export const vi: Record<string, string> = {
settingsShellArgsPlaceholder: 'vd: -l (để trống = mặc định)',
settingsTabsTitle: 'Nội dung thanh bên',
settingsViewersTitle: 'Trình xem tệp',
settingsMobileTitle: "Di động",
settingsMobileNoAutoOpenTitle: "Không tự mở trên màn hình hẹp",
settingsMobileNoAutoOpenDesc: "Trên điện thoại / màn hình hẹp, tác nhân con hay tác vụ nền mới sẽ không tự chuyển sang trang Tác vụ nữa (vẫn mở thủ công được)",
settingsMobileTreeTitle: "Trang Tác vụ mặc định dạng cây",
settingsMobileTreeDesc: "Trên điện thoại / màn hình hẹp, trang Tác vụ mở dưới dạng cây cổ điển thay vì sơ đồ quy trình; nút chuyển trong trang vẫn hoạt động",
settingsGeneralTitle: 'Chung',
settingsPopup: 'Cài đặt tính năng',
settingsViewerCatchAll: 'Catch-all: mọi tệp',
+5
View File
@@ -267,6 +267,11 @@ export const zhHK: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhMO: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhTW: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+10
View File
@@ -264,6 +264,11 @@ export const zh = {
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsMobileTitle: '手机',
settingsMobileNoAutoOpenTitle: '窄屏时不自动弹出',
settingsMobileNoAutoOpenDesc: '在手机 / 窄屏下,新的子代理或后台任务都不再自动切到任务页(页内仍可手动打开)',
settingsMobileTreeTitle: '窄屏时任务页默认树状图',
settingsMobileTreeDesc: '手机 / 窄屏下任务页默认用经典树,而不是工作流图;页内切换仍可临时改成图',
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
@@ -738,6 +743,11 @@ export const en: Record<keyof typeof zh, string> = {
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsMobileTitle: 'Mobile',
settingsMobileNoAutoOpenTitle: "Don't auto-open on narrow screens",
settingsMobileNoAutoOpenDesc: "On a phone / narrow viewport, a new subagent or background job no longer switches to the Tasks page by itself (you can still open it by hand)",
settingsMobileTreeTitle: 'Default the Tasks page to the tree',
settingsMobileTreeDesc: "On a phone / narrow viewport the Tasks page opens as the classic tree instead of the workflow graph; the in-page toggle still switches it ad hoc",
settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file',
+6
View File
@@ -48,6 +48,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
tasksViewMode: record.tasksViewMode === 'tree' || record.tasksViewMode === 'graph'
? record.tasksViewMode
: SIDEBAR_PREFS_DEFAULTS.tasksViewMode,
mobileNoAutoOpen: typeof record.mobileNoAutoOpen === 'boolean'
? record.mobileNoAutoOpen
: SIDEBAR_PREFS_DEFAULTS.mobileNoAutoOpen,
mobileDefaultTree: typeof record.mobileDefaultTree === 'boolean'
? record.mobileDefaultTree
: SIDEBAR_PREFS_DEFAULTS.mobileDefaultTree,
agentOpenTools: typeof record.agentOpenTools === 'boolean'
? record.agentOpenTools
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
+11
View File
@@ -182,6 +182,11 @@ export function useHostFeeds(feeds: {
* baseline starts at the current count — so a deliberate layout is never
* fought.
*
* MOBILE: while the viewport is narrow (the plugin's own bracket), the
* `mobileNoAutoOpen` preference suppresses this takeover entirely — on a
* phone the Tasks page costs the whole screen, and the reader has not asked
* for it. The viewport is read when the trigger FIRES, like the park gate.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as
@@ -202,6 +207,7 @@ export function useHostFeeds(feeds: {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, AUTO_OPEN_DEBOUNCE_MS)
@@ -225,6 +231,10 @@ export function useHostFeeds(feeds: {
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
* one session, and each should surface.
*
* MOBILE: `mobileNoAutoOpen` suppresses this one too on a narrow viewport
* (one switch for both triggers — a phone should not be taken over by either
* kind of background work).
*
* The roster is PUSHED by the host's own client jobs service: one
* reference-counted `watchRows` stream for the current session replaces the
* 3s `jobs.list` poll this used to run. A fresh page load never triggers —
@@ -260,6 +270,7 @@ export function useHostFeeds(feeds: {
if (prev === undefined) return
if (!detectNewJob(prev, rows, jobWatchStartRef.current)) return
if (!store.getPrefs().autoOpenJobs) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, [jobsService, jobsSnapshot, sessionId, store, ctx])
+26 -61
View File
@@ -148,7 +148,11 @@
padding: 0;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
/* 8px, the same rounding the floating window wears: the page's cards and
its windows are one family, and the square-ish 4px read as a table cell
next to them. The border is INSIDE the box (border-box + the layout's
min-height), so a thicker border never changes the card's outer size. */
border-radius: 8px;
cursor: pointer;
overflow: hidden;
}
@@ -157,17 +161,18 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* The current session: the card keeps the native selected language — the
same 2px accent bar the tree row wears, never a shadow. */
.nodeCurrent::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
/* The current session: a THICKER accent edge (the reader's ask was "边框加粗
高亮", and the previous 2px left stripe was the card's one leftover piece of
chrome — it also doubled the level edge on the root card, which is why the
two read as a single bar).
2px, not 1.5px: Chromium FLOORS a sub-pixel border width at device-pixel
ratio 1, so 1.5px computes (and paints) as 1px — measured on the real host,
where the "thicker" edge was indistinguishable from the plain one. The border
sits inside the border box, so the layout's height/width budgets are
untouched (the card's own content absorbs the extra pixel). */
.nodeCurrent {
border-color: var(--dsw-alias-state-business-primary);
border-width: 2px;
}
/* Teammates and the workflow run wear the accent edge; the run adds its
@@ -193,12 +198,14 @@
/* The group colour of a node is its DEPTH. The host ships no chart palette and
the skin contract forbids literal colours, so the ramp mixes the one accent
token: level 0 is the accent itself, deeper levels recede. The level also
tints the top segment faintly, so a wide graph still reads as bands. */
.depth0 { --card-edge: 100%; --card-tint: 7%; }
.depth1 { --card-edge: 72%; --card-tint: 5%; }
.depth2 { --card-edge: 48%; --card-tint: 3.5%; }
.depth3 { --card-edge: 28%; --card-tint: 2.5%; }
token. The level is carried by the TOP SEGMENT'S FAINT TINT alone — the 2px
left edge it used to add was the one piece of card chrome the reader asked
to remove ("就是左侧的竖条,这个去掉"), and the tint was lifted a notch
(7/5/3.5/2.5% → 9/7/5/3.5%) to compensate for the lost contrast. */
.depth0 { --card-tint: 9%; }
.depth1 { --card-tint: 7%; }
.depth2 { --card-tint: 5%; }
.depth3 { --card-tint: 3.5%; }
.cardTop {
position: relative;
@@ -207,25 +214,12 @@
gap: 2px;
flex: 1;
min-width: 0;
padding: 6px 8px 5px 10px;
padding: 6px 8px 5px;
background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-tint, 6%), var(--dsw-alias-bg-layer-1)
);
}
/* The level edge: 2px, full height of the segment, never a second border. */
.cardTop::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-edge, 100%), transparent
);
}
.cardBadges {
display: flex;
align-items: center;
@@ -857,15 +851,6 @@
white-space: nowrap;
}
/* The job output well reads on the host's own code-block rhythm (11px/19px
code): unwrapped and wrapped terminal lines share one leading, and the well
matches every markdown code block in the app. Scoped so the shared `.popPre`
metrics the node/task cards rely on stay untouched — and doubled, so the
base rule's own `font-size` / `line-height` cannot win on sheet order. */
.jobPopPre.jobPopPre {
font: var(--dsw-font-markdown-code-block, 11px/19px var(--ds-font-family-code, monospace));
}
/* The armed (first-click) confirm of the popover's kill: the same danger chip
the drawer's terminate button arms into, so one destructive action reads the
same in both places. */
@@ -938,26 +923,6 @@
font-size: 11px;
}
/* An inset well: the hairline is definition, the fill is the surface. The
160px cap is the page's one scroll-well height — the member list, the run's
phase list, the output well and the task description all take it, so no card
in the family scrolls at a different depth. */
.popPre {
margin: 4px 0 0;
padding: 8px;
max-height: 160px;
overflow: auto;
background: var(--dsw-alias-bg-base);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 3px;
font-family: var(--ds-font-family-code, monospace);
font-size: 11px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
word-break: break-word;
}
/* A section inside a card opens with the family's one hairline: 8px of air on
both sides of the rule (the card's own 4px gap plus 4px here, and the 8px
padding below), exactly the beat `.jobPopActions` closes a card with. */
+2
View File
@@ -81,6 +81,8 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
tasksViewMode: z.union([z.const('graph'), z.const('tree')]).default('graph'),
mobileNoAutoOpen: z.boolean().default(true),
mobileDefaultTree: z.boolean().default(true),
agentOpenTools: z.boolean().default(false),
editorExplorer: z.boolean().default(false),
workspaceFence: z.boolean().default(true),
+21
View File
@@ -27,6 +27,23 @@ export interface SidebarPrefs {
* classic indentation tree (the in-page toggle still flips it ad hoc).
*/
tasksViewMode: 'graph' | 'tree'
/**
* MOBILE ADAPTATION (narrow viewports, `isNarrowWidth`): while the viewport
* is narrow, do not auto-activate the Tasks page for background activity —
* it suppresses BOTH triggers (`autoOpenSubagent` and `autoOpenJobs`) at
* once, because on a phone the takeover costs the whole screen. The two
* individual switches keep their own meaning on wide viewports.
*/
mobileNoAutoOpen: boolean
/**
* MOBILE ADAPTATION (narrow viewports): open the Tasks page in the classic
* TREE by default instead of the workflow graph — a narrow screen cannot
* show a layered graph legibly, while the tree's indentation still reads.
* It only picks the DEFAULT: the in-page view toggle still flips this
* session's page ad hoc, and `tasksViewMode` keeps deciding on wide
* viewports.
*/
mobileDefaultTree: boolean
/**
* Whether the model-facing `sidebar_open` tool is injected into the
* model's toolset — one tool that lets the model actively open a local
@@ -157,6 +174,10 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
// Both mobile adaptations are ON by default: a phone is the case they exist
// for, and each one only ever changes what happens on a NARROW viewport.
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,