dsh-taskboard 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -215,13 +215,17 @@ pnpm 的构建授权——按报错把 key 加进 profile 的 `pnpm-workspace.ya
215
215
  git clone https://github.com/cloader/dsh-taskboard.git
216
216
  cd dsh-taskboard
217
217
  npm install && npm run build # host ESM + client CJS 双构建
218
- npm test # vitest 全量(232 项)
218
+ npm test # vitest 全量(233 项)
219
219
  node tests/manual-git-e2e.mjs # 真 git 端到端手测(worktree 全链路 + 续跑 + diff 查看器)
220
220
  node scripts/screenshot.mjs # 重新生成 img/ 截图(需本机 Edge)
221
221
  ```
222
222
 
223
223
  ## 升级日志
224
224
 
225
+ ### 0.6.1
226
+
227
+ - **修复:`/` 快捷补全弹层被任务弹窗滚动容器裁剪**:弹层 portal 到 document.body 并 fixed 锚定输入框,不再被表单滚动容器裁剪;上方空间不足自动下翻、贴边收拢,并随滚动/缩放实时跟随
228
+ - **修复:键盘 ↑/↓ 选择补全项时列表不滚动**:高亮项自动滚入视野(含 wrap-around),直调列表 scrollTop 避免连带滚动模态表体
225
229
  ### 0.6.0
226
230
 
227
231
  - **任务弹窗左右双栏宽屏布局、输入框 / 快捷补全与执行权限选择: [@jw5555555555](https://github.com/jw5555555555)([#14](https://github.com/cloader/dsh-taskboard/pull/14))**
package/lib/client.js CHANGED
@@ -8,6 +8,7 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let react_dom_client = require("react-dom/client");
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
+ let react_dom = require("react-dom");
11
12
 
12
13
  //#region src/client/api.ts
13
14
  /** Unwrap the envelope or throw a readable error. */
@@ -2857,9 +2858,11 @@ window.__ModuleLoader__.load({
2857
2858
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary, #1f2328) 18%, transparent);
2858
2859
  }
2859
2860
 
2860
- /* Slash Autocomplete Popup */
2861
+ /* Slash Autocomplete Popup. Fixed positioning (left/top/width/maxHeight/z-index)
2862
+ * is set INLINE by SlashPromptInput: the popup is portaled to document.body and
2863
+ * anchored to the textarea's viewport rect, so the scrollable modal body can no
2864
+ * longer clip its top (0.6.0 field report). Only the visual shell lives here. */
2861
2865
  .dsh-atb-slash-popup {
2862
- position: absolute; left: 0; bottom: calc(100% + 6px); width: 100%; max-height: 240px; z-index: 100;
2863
2866
  display: flex; flex-direction: column; overflow: hidden; border-radius: 10px;
2864
2867
  background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
2865
2868
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28));
@@ -3190,7 +3193,7 @@ window.__ModuleLoader__.load({
3190
3193
  * @module dsh-taskboard/shared/version
3191
3194
  */
3192
3195
  /** The package version (must equal package.json "version"). */
3193
- const PLUGIN_VERSION = "0.6.0";
3196
+ const PLUGIN_VERSION = "0.6.1";
3194
3197
 
3195
3198
  //#endregion
3196
3199
  //#region src/client/board/labels.ts
@@ -4750,6 +4753,9 @@ window.__ModuleLoader__.load({
4750
4753
  function SlashPromptInput({ value, onChange, controller, placeholder, rows = 4, maxLength = 8e3, disabled = false, autoFocus = false, className, ariaLabel }) {
4751
4754
  const t = useT();
4752
4755
  const textareaRef = (0, react.useRef)(null);
4756
+ const popupRef = (0, react.useRef)(null);
4757
+ const listRef = (0, react.useRef)(null);
4758
+ const [popupStyle, setPopupStyle] = (0, react.useState)({});
4753
4759
  const [hostCompletions, setHostCompletions] = (0, react.useState)(void 0);
4754
4760
  const completions = (0, react.useMemo)(() => {
4755
4761
  const merge = (defaults, host) => {
@@ -4796,6 +4802,20 @@ window.__ModuleLoader__.load({
4796
4802
  (0, react.useEffect)(() => {
4797
4803
  if (selectedIndex >= filteredItems.length) setSelectedIndex(Math.max(0, filteredItems.length - 1));
4798
4804
  }, [filteredItems.length, selectedIndex]);
4805
+ (0, react.useLayoutEffect)(() => {
4806
+ if (!popupOpen) return;
4807
+ const list = listRef.current;
4808
+ const active = list?.children[selectedIndex];
4809
+ if (list === null || !(active instanceof HTMLElement)) return;
4810
+ const listRect = list.getBoundingClientRect();
4811
+ const itemRect = active.getBoundingClientRect();
4812
+ if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top;
4813
+ else if (itemRect.bottom > listRect.bottom) list.scrollTop += itemRect.bottom - listRect.bottom;
4814
+ }, [
4815
+ popupOpen,
4816
+ selectedIndex,
4817
+ filteredItems
4818
+ ]);
4799
4819
  const checkSlashTrigger = () => {
4800
4820
  const el = textareaRef.current;
4801
4821
  if (el === null) return;
@@ -4860,6 +4880,42 @@ window.__ModuleLoader__.load({
4860
4880
  }
4861
4881
  }
4862
4882
  };
4883
+ const positionPopup = (0, react.useCallback)(() => {
4884
+ const anchor = textareaRef.current;
4885
+ if (anchor === null) return;
4886
+ const rect = anchor.getBoundingClientRect();
4887
+ const gap = 6;
4888
+ const margin = 8;
4889
+ const vh = window.innerHeight;
4890
+ const measured = popupRef.current?.offsetHeight ?? 0;
4891
+ const natural = measured > 0 ? measured : 240;
4892
+ const roomAbove = rect.top - gap - margin;
4893
+ const roomBelow = vh - margin - (rect.bottom + gap);
4894
+ const openBelow = roomBelow > roomAbove;
4895
+ const height = Math.min(natural, Math.max(openBelow ? roomBelow : roomAbove, 120));
4896
+ const top = openBelow ? rect.bottom + gap : rect.top - gap - height;
4897
+ setPopupStyle((prev) => prev.left === rect.left && prev.top === top && prev.width === rect.width && prev.maxHeight === height ? prev : {
4898
+ position: "fixed",
4899
+ left: rect.left,
4900
+ top,
4901
+ width: rect.width,
4902
+ maxHeight: height,
4903
+ zIndex: 100
4904
+ });
4905
+ }, []);
4906
+ (0, react.useLayoutEffect)(() => {
4907
+ if (!popupOpen) return;
4908
+ positionPopup();
4909
+ });
4910
+ (0, react.useEffect)(() => {
4911
+ if (!popupOpen) return;
4912
+ window.addEventListener("scroll", positionPopup, true);
4913
+ window.addEventListener("resize", positionPopup);
4914
+ return () => {
4915
+ window.removeEventListener("scroll", positionPopup, true);
4916
+ window.removeEventListener("resize", positionPopup);
4917
+ };
4918
+ }, [popupOpen, positionPopup]);
4863
4919
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4864
4920
  className: `dsh-atb-prompt-wrap ${className ?? ""}`,
4865
4921
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4881,8 +4937,10 @@ window.__ModuleLoader__.load({
4881
4937
  onKeyUp: checkSlashTrigger,
4882
4938
  onClick: checkSlashTrigger,
4883
4939
  onKeyDown: handleKeyDown
4884
- }), popupOpen && filteredItems.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4940
+ }), popupOpen && filteredItems.length > 0 && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4941
+ ref: popupRef,
4885
4942
  className: "dsh-atb-slash-popup",
4943
+ style: popupStyle,
4886
4944
  role: "listbox",
4887
4945
  "aria-label": t("slash.aria"),
4888
4946
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4895,6 +4953,7 @@ window.__ModuleLoader__.load({
4895
4953
  children: t("slash.hint")
4896
4954
  })]
4897
4955
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4956
+ ref: listRef,
4898
4957
  className: "dsh-atb-slash-list",
4899
4958
  children: filteredItems.map((item, idx) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4900
4959
  role: "option",
@@ -4925,7 +4984,7 @@ window.__ModuleLoader__.load({
4925
4984
  ]
4926
4985
  }, `${item.kind}-${item.name}`))
4927
4986
  })]
4928
- })]
4987
+ }), document.body)]
4929
4988
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4930
4989
  className: "dsh-atb-prompt-foot",
4931
4990
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, optional per-task git-worktree isolation (dedicated task branches, commit evidence, one-click merge), host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.6.0",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * @module dsh-taskboard/client/board/SlashPromptInput
8
8
  */
9
- import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent } from 'react'
10
+ import { createPortal } from 'react-dom'
10
11
  import type { BoardController } from '../controller.ts'
11
12
  import type { PromptCompletionItem } from '../../shared/api.ts'
12
13
  import { useT, type Translate } from '../i18n/runtime.ts'
@@ -83,6 +84,10 @@ export function SlashPromptInput({
83
84
  }: SlashPromptInputProps) {
84
85
  const t = useT()
85
86
  const textareaRef = useRef<HTMLTextAreaElement>(null)
87
+ const popupRef = useRef<HTMLDivElement>(null)
88
+ const listRef = useRef<HTMLDivElement>(null)
89
+ // Inline fixed-position style for the portaled popup (set by positionPopup).
90
+ const [popupStyle, setPopupStyle] = useState<CSSProperties>({})
86
91
 
87
92
  // Autocomplete state: only HOST-provided items are stateful; the built-in
88
93
  // defaults are re-derived per render so their descriptions follow the
@@ -131,6 +136,22 @@ export function SlashPromptInput({
131
136
  }
132
137
  }, [filteredItems.length, selectedIndex])
133
138
 
139
+ // Keep the keyboard-highlighted option visible inside the scrolling list:
140
+ // mouse hovering only ever targets rendered rows, but ArrowUp/ArrowDown can
141
+ // move the highlight past the clipped edge. Adjust the list's scrollTop
142
+ // directly from rect deltas — NOT scrollIntoView, which would also scroll
143
+ // ancestor containers (the modal body behind the portaled popup).
144
+ useLayoutEffect(() => {
145
+ if (!popupOpen) return
146
+ const list = listRef.current
147
+ const active = list?.children[selectedIndex]
148
+ if (list === null || !(active instanceof HTMLElement)) return
149
+ const listRect = list.getBoundingClientRect()
150
+ const itemRect = active.getBoundingClientRect()
151
+ if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top
152
+ else if (itemRect.bottom > listRect.bottom) list.scrollTop += itemRect.bottom - listRect.bottom
153
+ }, [popupOpen, selectedIndex, filteredItems])
154
+
134
155
  // Detect slash typing on cursor movement or text change
135
156
  const checkSlashTrigger = (): void => {
136
157
  const el = textareaRef.current
@@ -207,6 +228,51 @@ export function SlashPromptInput({
207
228
  }
208
229
  }
209
230
 
231
+ // The popup is portaled to document.body and fixed-positioned from the
232
+ // textarea's viewport rect: an absolute popup inside the scrollable modal
233
+ // body was clipped at the container's top edge (0.6.0 field report).
234
+ // Opens above by preference, flips below when the top is tight, and clamps
235
+ // to the viewport (maxHeight shrinks; the list scrolls internally).
236
+ const positionPopup = useCallback((): void => {
237
+ const anchor = textareaRef.current
238
+ if (anchor === null) return
239
+ const rect = anchor.getBoundingClientRect()
240
+ const gap = 6
241
+ const margin = 8
242
+ const vh = window.innerHeight
243
+ const measured = popupRef.current?.offsetHeight ?? 0
244
+ const natural = measured > 0 ? measured : 240
245
+ const roomAbove = rect.top - gap - margin
246
+ const roomBelow = vh - margin - (rect.bottom + gap)
247
+ const openBelow = roomBelow > roomAbove
248
+ const height = Math.min(natural, Math.max(openBelow ? roomBelow : roomAbove, 120))
249
+ const top = openBelow ? rect.bottom + gap : rect.top - gap - height
250
+ // Bail out (return prev) when unchanged: the layout effect below runs on
251
+ // every open render, and a fresh object here would re-render forever.
252
+ setPopupStyle(prev => (prev.left === rect.left && prev.top === top && prev.width === rect.width && prev.maxHeight === height
253
+ ? prev
254
+ : { position: 'fixed', left: rect.left, top, width: rect.width, maxHeight: height, zIndex: 100 }))
255
+ }, [])
256
+
257
+ // Reposition on every open render: the popup height follows the filtered
258
+ // item count, so typing changes the geometry too.
259
+ useLayoutEffect(() => {
260
+ if (!popupOpen) return
261
+ positionPopup()
262
+ })
263
+
264
+ // Follow scrolling and viewport resizes while open (capture phase: the
265
+ // modal body scrolls, the window itself does not).
266
+ useEffect(() => {
267
+ if (!popupOpen) return
268
+ window.addEventListener('scroll', positionPopup, true)
269
+ window.addEventListener('resize', positionPopup)
270
+ return () => {
271
+ window.removeEventListener('scroll', positionPopup, true)
272
+ window.removeEventListener('resize', positionPopup)
273
+ }
274
+ }, [popupOpen, positionPopup])
275
+
210
276
  return (
211
277
  <div className={`dsh-atb-prompt-wrap ${className ?? ''}`}>
212
278
  <div className="dsh-atb-prompt-inner">
@@ -229,14 +295,15 @@ export function SlashPromptInput({
229
295
  onKeyDown={handleKeyDown}
230
296
  />
231
297
 
232
- {/* Slash Autocomplete Popup */}
233
- {popupOpen && filteredItems.length > 0 && (
234
- <div className="dsh-atb-slash-popup" role="listbox" aria-label={t('slash.aria')}>
298
+ {/* Slash Autocomplete Popup — portaled to document.body so the
299
+ scrollable modal body can never clip it (see positionPopup). */}
300
+ {popupOpen && filteredItems.length > 0 && createPortal(
301
+ <div ref={popupRef} className="dsh-atb-slash-popup" style={popupStyle} role="listbox" aria-label={t('slash.aria')}>
235
302
  <div className="dsh-atb-slash-head">
236
303
  <span className="dsh-atb-slash-title">{t('slash.title')}</span>
237
304
  <span className="dsh-atb-slash-hint">{t('slash.hint')}</span>
238
305
  </div>
239
- <div className="dsh-atb-slash-list">
306
+ <div ref={listRef} className="dsh-atb-slash-list">
240
307
  {filteredItems.map((item, idx) => (
241
308
  <div
242
309
  key={`${item.kind}-${item.name}`}
@@ -257,7 +324,8 @@ export function SlashPromptInput({
257
324
  </div>
258
325
  ))}
259
326
  </div>
260
- </div>
327
+ </div>,
328
+ document.body,
261
329
  )}
262
330
  </div>
263
331
 
@@ -868,9 +868,11 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
868
868
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary, #1f2328) 18%, transparent);
869
869
  }
870
870
 
871
- /* Slash Autocomplete Popup */
871
+ /* Slash Autocomplete Popup. Fixed positioning (left/top/width/maxHeight/z-index)
872
+ * is set INLINE by SlashPromptInput: the popup is portaled to document.body and
873
+ * anchored to the textarea's viewport rect, so the scrollable modal body can no
874
+ * longer clip its top (0.6.0 field report). Only the visual shell lives here. */
872
875
  .dsh-atb-slash-popup {
873
- position: absolute; left: 0; bottom: calc(100% + 6px); width: 100%; max-height: 240px; z-index: 100;
874
876
  display: flex; flex-direction: column; overflow: hidden; border-radius: 10px;
875
877
  background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
876
878
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28));
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.6.0'
9
+ export const PLUGIN_VERSION = '0.6.1'