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:
@@ -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%
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,11 @@ export const ar: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)',
|
||||
settingsTabsTitle: 'محتوى الشريط الجانبي',
|
||||
settingsViewersTitle: 'عارضات الملفات',
|
||||
settingsMobileTitle: "الجوال",
|
||||
settingsMobileNoAutoOpenTitle: "عدم الفتح التلقائي على الشاشات الضيقة",
|
||||
settingsMobileNoAutoOpenDesc: "على الهاتف أو الشاشة الضيقة، لن يؤدي وكيل فرعي أو مهمة خلفية جديدة إلى الانتقال تلقائيًا إلى صفحة المهام (يمكنك فتحها يدويًا)",
|
||||
settingsMobileTreeTitle: "افتراض الشجرة لصفحة المهام",
|
||||
settingsMobileTreeDesc: "على الهاتف أو الشاشة الضيقة تُفتح صفحة المهام كشجرة كلاسيكية بدل مخطط سير العمل (مفتاح الصفحة يظل يعمل)",
|
||||
settingsGeneralTitle: 'عام',
|
||||
settingsPopup: 'إعدادات الميزة',
|
||||
settingsViewerCatchAll: 'شامل: أي ملف',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -252,6 +252,11 @@ export const hi: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)',
|
||||
settingsTabsTitle: 'साइडबार सामग्री',
|
||||
settingsViewersTitle: 'फ़ाइल दर्शक',
|
||||
settingsMobileTitle: "मोबाइल",
|
||||
settingsMobileNoAutoOpenTitle: "छोटी स्क्रीन पर स्वतः न खोलें",
|
||||
settingsMobileNoAutoOpenDesc: "फ़ोन / छोटी स्क्रीन पर नया सबएजेंट या पृष्ठभूमि कार्य अपने आप कार्य पृष्ठ नहीं खोलेगा (मैन्युअल रूप से खोल सकते हैं)",
|
||||
settingsMobileTreeTitle: "कार्य पृष्ठ डिफ़ॉल्ट रूप से ट्री",
|
||||
settingsMobileTreeDesc: "फ़ोन / छोटी स्क्रीन पर कार्य पृष्ठ वर्कफ़्लो ग्राफ़ की जगह क्लासिक ट्री में खुलता है; पृष्ठ का टॉगल काम करता रहता है",
|
||||
settingsGeneralTitle: 'सामान्य',
|
||||
settingsPopup: 'सुविधा सेटिंग्स',
|
||||
settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -252,6 +252,11 @@ export const ja: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)',
|
||||
settingsTabsTitle: 'サイドバーの内容',
|
||||
settingsViewersTitle: 'ファイルプレビュー',
|
||||
settingsMobileTitle: "モバイル",
|
||||
settingsMobileNoAutoOpenTitle: "狭い画面では自動で開かない",
|
||||
settingsMobileNoAutoOpenDesc: "スマホ / 狭い画面では、新しいサブエージェントやバックグラウンドタスクがあってもタスクページへ自動で切り替わりません(手動では開けます)",
|
||||
settingsMobileTreeTitle: "タスクページはツリーを既定にする",
|
||||
settingsMobileTreeDesc: "スマホ / 狭い画面ではタスクページをワークフロー図ではなくツリーで開きます(ページ内の切り替えはそのまま使えます)",
|
||||
settingsGeneralTitle: '一般',
|
||||
settingsPopup: '機能設定',
|
||||
settingsViewerCatchAll: 'フォールバック:任意のファイル',
|
||||
|
||||
@@ -244,6 +244,11 @@ export const ko: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)',
|
||||
settingsTabsTitle: '사이드바 콘텐츠',
|
||||
settingsViewersTitle: '파일 미리보기',
|
||||
settingsMobileTitle: "모바일",
|
||||
settingsMobileNoAutoOpenTitle: "좁은 화면에서는 자동으로 열지 않기",
|
||||
settingsMobileNoAutoOpenDesc: "휴대폰 / 좁은 화면에서는 새 하위 에이전트나 백그라운드 작업이 있어도 작업 페이지로 자동 전환되지 않습니다(직접 열 수는 있습니다)",
|
||||
settingsMobileTreeTitle: "작업 페이지 기본을 트리로",
|
||||
settingsMobileTreeDesc: "휴대폰 / 좁은 화면에서 작업 페이지가 워크플로 그래프 대신 클래식 트리로 열립니다. 페이지 내 전환은 그대로 동작합니다",
|
||||
settingsGeneralTitle: '일반',
|
||||
settingsPopup: '기능 설정',
|
||||
settingsViewerCatchAll: '폴백: 모든 파일',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -249,6 +249,11 @@ export const ru: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)',
|
||||
settingsTabsTitle: 'Содержимое боковой панели',
|
||||
settingsViewersTitle: 'Просмотрщики файлов',
|
||||
settingsMobileTitle: "Мобильный",
|
||||
settingsMobileNoAutoOpenTitle: "Не открывать автоматически на узком экране",
|
||||
settingsMobileNoAutoOpenDesc: "На телефоне / узком экране новый субагент или фоновая задача больше не переключает на страницу задач само (открыть вручную можно)",
|
||||
settingsMobileTreeTitle: "Страница задач по умолчанию — дерево",
|
||||
settingsMobileTreeDesc: "На телефоне / узком экране страница задач открывается классическим деревом, а не графом процесса; переключатель на странице продолжает работать",
|
||||
settingsGeneralTitle: 'Общие',
|
||||
settingsPopup: 'Настройки функции',
|
||||
settingsViewerCatchAll: 'Универсальный: любой файл',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -252,6 +252,11 @@ export const th: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)',
|
||||
settingsTabsTitle: 'เนื้อหาแถบด้านข้าง',
|
||||
settingsViewersTitle: 'ตัวพรีวิวไฟล์',
|
||||
settingsMobileTitle: "มือถือ",
|
||||
settingsMobileNoAutoOpenTitle: "ไม่เปิดอัตโนมัติบนจอแคบ",
|
||||
settingsMobileNoAutoOpenDesc: "บนมือถือ / จอแคบ เอเจนต์ย่อยหรืองานเบื้องหลังใหม่จะไม่สลับไปหน้าภารกิจให้เองอีก (ยังเปิดเองได้)",
|
||||
settingsMobileTreeTitle: "หน้าภารกิจใช้ทรีเป็นค่าเริ่มต้น",
|
||||
settingsMobileTreeDesc: "บนมือถือ / จอแคบ หน้าภารกิจจะเปิดเป็นทรีแบบคลาสสิกแทนกราฟเวิร์กโฟลว์ ปุ่มสลับในหน้ายังใช้ได้",
|
||||
settingsGeneralTitle: 'ทั่วไป',
|
||||
settingsPopup: 'การตั้งค่าคุณสมบัติ',
|
||||
settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -267,6 +267,11 @@ export const zhHK: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
|
||||
settingsTabsTitle: '側邊欄內容',
|
||||
settingsViewersTitle: '檔案預覽',
|
||||
settingsMobileTitle: "手機",
|
||||
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
|
||||
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
|
||||
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
|
||||
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
|
||||
settingsGeneralTitle: '一般',
|
||||
settingsPopup: '功能設定',
|
||||
settingsViewerCatchAll: '兜底:任意檔案',
|
||||
|
||||
@@ -267,6 +267,11 @@ export const zhMO: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
|
||||
settingsTabsTitle: '側邊欄內容',
|
||||
settingsViewersTitle: '檔案預覽',
|
||||
settingsMobileTitle: "手機",
|
||||
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
|
||||
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
|
||||
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
|
||||
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
|
||||
settingsGeneralTitle: '一般',
|
||||
settingsPopup: '功能設定',
|
||||
settingsViewerCatchAll: '兜底:任意檔案',
|
||||
|
||||
@@ -267,6 +267,11 @@ export const zhTW: Record<string, string> = {
|
||||
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
|
||||
settingsTabsTitle: '側邊欄內容',
|
||||
settingsViewersTitle: '檔案預覽',
|
||||
settingsMobileTitle: "手機",
|
||||
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
|
||||
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
|
||||
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
|
||||
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
|
||||
settingsGeneralTitle: '一般',
|
||||
settingsPopup: '功能設定',
|
||||
settingsViewerCatchAll: '兜底:任意檔案',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user