dsh-plugin-deck-studio 0.2.14 → 0.2.25

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/client.js CHANGED
@@ -339,8 +339,62 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
339
339
  const session = useSession((value) => value);
340
340
  const active = useConversation((value) => value).activeTargets;
341
341
  const phase = (!session.blank || session.running || active.size > 0) ? "active" : "hero";
342
+ /**
343
+ * 0.2.23:**回退** 0.2.22 的 sessionId 透传。
344
+ *
345
+ * 为什么当时要透传:宿主的 composer 链是
346
+ * useSessionStatus(s => s.get(sessionId)?.pendingInteraction) → renderSlotChain(...)
347
+ * 审批面板要靠 pendingInteraction 才渲染,我们没传 sessionId 就永远不弹 ask。
348
+ *
349
+ * 为什么回退:透传之后真机上「点会话 → 工作台闪出 → 不到 1s 整片变灰」——
350
+ * 说明在 shell.overlay 这个上下文里让 composer 链去查宿主的主区 pendingInteraction 会直接崩掉
351
+ * 整棵 Dock 子树(外面看不到原因,只看到灰屏)。宁可先保住对话可用,再想别的办法把 ask 露出来。
352
+ */
342
353
  return renderFactorySlot("conversation.content", { variant: "embedded", phase, hero: phase === "hero" }, { slots: { views: DeckChatView } });
343
354
  }
355
+
356
+ /**
357
+ * 0.2.23:Dock 子树的错误边界。
358
+ *
359
+ * 之前 slot 里任何一个组件抛错,整个 overlay 会变成一片空白灰,用户完全不知道发生了什么
360
+ * (这次灰屏就是)。用边界把错误收住,至少把原因写在屏幕上 + 给重试和「去主区对话」的出路。
361
+ */
362
+ class DockErrorBoundary extends React.Component {
363
+ constructor(props) {
364
+ super(props);
365
+ this.state = { error: null };
366
+ }
367
+ static getDerivedStateFromError(error) {
368
+ return { error };
369
+ }
370
+ componentDidCatch(error) {
371
+ try {
372
+ ctx.logger?.warn?.("deck-studio: chat dock crashed", error);
373
+ } catch {
374
+ /* 日志不可用时忽略 */
375
+ }
376
+ }
377
+ render() {
378
+ if (!this.state.error) return this.props.children;
379
+ const message = String(this.state.error?.message ?? this.state.error ?? "未知错误");
380
+ return h(
381
+ "div",
382
+ { style: { height: "100%", padding: 16, overflow: "auto", background: "#fdf7f3", color: "#7a3b2e", fontSize: 12, lineHeight: 1.7 } },
383
+ h("div", { style: { fontWeight: 600, marginBottom: 6 } }, "对话区加载失败"),
384
+ h("div", { style: { color: "#5c6464", wordBreak: "break-word" } }, message),
385
+ h(
386
+ "button",
387
+ {
388
+ type: "button",
389
+ style: { marginTop: 10, marginRight: 8, padding: "4px 10px", fontSize: 12, cursor: "pointer" },
390
+ onClick: () => this.setState({ error: null }),
391
+ },
392
+ "重试",
393
+ ),
394
+ h("div", { style: { marginTop: 10, color: "#5c6464" } }, "也可以点顶栏「☰ DSH」去宿主主区的对话里继续。"),
395
+ );
396
+ }
397
+ }
344
398
  /** Dock:测量工作台对话占位矩形;用 retain 的会话引用喂 SessionProvider。 */
345
399
  function DeckChatDock({ SessionProvider, renderSlot }) {
346
400
  const sessionId = React.useSyncExternalStore(subscribeDockSession, () => dockState.sessionId);
@@ -389,10 +443,18 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
389
443
  try { frameEl.contentWindow.postMessage({ type: "dsh-deck-studio:chat-dock" }, "*"); } catch { /* 不可达时降级卡留着 */ }
390
444
  }, [rect, reference]);
391
445
  if (!SessionProvider || !renderSlot || !reference || !rect) return null;
446
+ // 0.2.23:把覆盖层夹在可视区内。测量一旦拿到「整窗」尺寸(面板刚切换、iframe 还没布局),
447
+ // 这个 fixed 覆盖层会把整个应用盖住,看起来就是「整片灰、啥也没有」。
448
+ const viewportW = typeof window !== "undefined" ? window.innerWidth : rect.width;
449
+ const viewportH = typeof window !== "undefined" ? window.innerHeight : rect.height;
450
+ const left = Math.max(0, Math.min(rect.left, Math.max(0, viewportW - 120)));
451
+ const top = Math.max(0, Math.min(rect.top, Math.max(0, viewportH - 120)));
452
+ const width = Math.max(120, Math.min(rect.width, viewportW - left));
453
+ const height = Math.max(160, Math.min(rect.height, viewportH - top));
392
454
  return h("div", {
393
455
  "data-deck-studio-chat-dock": "",
394
- style: { position: "fixed", left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px`, zIndex: 40, display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden", background: "var(--paper,#faf8f2)", borderRight: "1px solid var(--line,#e8e2d6)", pointerEvents: "auto" },
395
- }, h(SessionProvider, { session: reference }, renderSlot(CHAT_SLOT, {})));
456
+ style: { position: "fixed", left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px`, zIndex: 40, display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden", background: "var(--paper,#faf8f2)", borderRight: "1px solid var(--line,#e8e2d6)", pointerEvents: "auto" },
457
+ }, h(DockErrorBoundary, null, h(SessionProvider, { session: reference }, renderSlot(CHAT_SLOT, {}))));
396
458
  }
397
459
  // 工作台左栏会把 DSH 原生 Chat 以 `/?decklineChat=1` 嵌进来(同源 iframe)。
398
460
  // 那个嵌套的 DSH 实例不再自动打开工作台,否则会层层嵌套(F-68)。
@@ -423,6 +485,140 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
423
485
  };
424
486
  const closeWorkbench = () => layout?.selectPanel?.(null);
425
487
 
488
+ /**
489
+ * 0.2.17:从 DSH 侧栏的会话直接拉起 Deckline 工作区(对齐 html-deck-studio 的同款交互)。
490
+ *
491
+ * 宿主把「当前主区会话」记在 `sessions.list` 的 `retainedBy.mainView` 上。切到一本
492
+ * 已绑定 deck / design 的会话时,自动打开工作台并切到那本 deck —— 省掉「先点 Deckline
493
+ * 面板、再选 deck」两步;会话行「⋯」菜单里也给一个显式入口。
494
+ */
495
+ const activeMainSessionId = () => {
496
+ try {
497
+ const list = ctx.sessions?.list?.getSnapshot?.();
498
+ if (!list?.byId) return "";
499
+ return Object.values(list.byId).find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? "";
500
+ } catch {
501
+ return "";
502
+ }
503
+ };
504
+ const activePanelIdOf = () => {
505
+ try {
506
+ return layout?.panelInfo?.getSnapshot?.()?.activePanelId ?? null;
507
+ } catch {
508
+ return null;
509
+ }
510
+ };
511
+ /** 会话 → 绑定实体(服务端绑定表;3s 内复用,列表频繁变化时不重复打接口)。 */
512
+ let bindingsCache = { at: 0, rows: [] };
513
+ const readSessionBindings = async () => {
514
+ if (Date.now() - bindingsCache.at < 3000) return bindingsCache.rows;
515
+ try {
516
+ const res = await fetch("/api/deck-studio.session-bindings", { headers: { accept: "application/json" } });
517
+ const data = await res.json();
518
+ bindingsCache = { at: Date.now(), rows: Array.isArray(data?.bindings) ? data.bindings : [] };
519
+ } catch {
520
+ bindingsCache = { at: Date.now(), rows: bindingsCache.rows };
521
+ }
522
+ return bindingsCache.rows;
523
+ };
524
+ /**
525
+ * 待发往工作台 iframe 的「打开某个 deck / design」指令。
526
+ *
527
+ * 0.2.21:发出去**不清空**——面板刚挂载时 iframe 里的脚本还没起来,早发的那几帧会被丢掉,
528
+ * 表现成「工作区弹出来了但停在首页」。现在等 iframe 回 `open-binding-ack` 才算送达,
529
+ * 期间由 ready 消息 + 多档定时重试兜底;20s 还没 ack 就放弃,避免下次误打开旧 deck。
530
+ */
531
+ let pendingOpen = null;
532
+ const flushPendingOpen = () => {
533
+ const pending = pendingOpen;
534
+ const win = frameState.el?.contentWindow;
535
+ if (!pending || !win) return;
536
+ try {
537
+ win.postMessage({ type: "dsh-deck-studio:open-binding", ...pending }, "*");
538
+ } catch {
539
+ /* iframe 还没就绪:等 ready 消息或下一次重试 */
540
+ }
541
+ };
542
+ const scheduleFlush = () => {
543
+ [60, 220, 600, 1400, 2600, 4200].forEach((delay) => window.setTimeout(flushPendingOpen, delay));
544
+ };
545
+ const openBindingInWorkbench = (target) => {
546
+ if (!target || (!target.id && !target.notice)) return false;
547
+ openWorkbench();
548
+ pendingOpen = { kind: target.kind ?? "deck", id: target.id ?? "", title: target.title ?? "", notice: target.notice ?? "" };
549
+ flushPendingOpen();
550
+ scheduleFlush();
551
+ const expected = pendingOpen;
552
+ window.setTimeout(() => {
553
+ if (pendingOpen === expected) pendingOpen = null;
554
+ }, 20000);
555
+ return true;
556
+ };
557
+ /** 侧栏点了一个会话:已绑定就直接进那本 deck;没绑定就进工作台并说明怎么绑。 */
558
+ const openSessionInWorkbench = async (sessionId, options = {}) => {
559
+ const id = String(sessionId ?? "").trim();
560
+ if (!id) return false;
561
+ const rows = await readSessionBindings();
562
+ const binding = rows.find((row) => row?.sessionId === id) ?? null;
563
+ if (!binding) {
564
+ if (options.auto) return false;
565
+ return openBindingInWorkbench({
566
+ kind: "none",
567
+ id: "",
568
+ notice: "这个会话还没有绑定 deck / 风格:先选一本 deck,用「+」新建或切换绑定。",
569
+ });
570
+ }
571
+ return openBindingInWorkbench({
572
+ kind: binding.kind === "design" ? "design" : "deck",
573
+ id: binding.id,
574
+ title: binding.title ?? "",
575
+ });
576
+ };
577
+ /** 会话行「⋯」菜单项:在 Deckline 里打开这个会话绑定的 deck(未绑定给可见提示)。 */
578
+ function OpenDecklineMenuItem({ sessionId, useMenuOpenState }) {
579
+ const menu = typeof useMenuOpenState === "function" ? useMenuOpenState : () => [false, () => {}];
580
+ const [, setMenuOpen] = menu();
581
+ const [busy, setBusy] = React.useState(false);
582
+ const label = busy ? "正在打开 Deckline…" : "在 Deckline 打开";
583
+ const onSelect = () => {
584
+ try {
585
+ setMenuOpen(false);
586
+ } catch {
587
+ /* 菜单状态不可用时仍然执行动作 */
588
+ }
589
+ if (busy) return;
590
+ setBusy(true);
591
+ void openSessionInWorkbench(sessionId).finally(() => setBusy(false));
592
+ };
593
+ return h(
594
+ "button",
595
+ {
596
+ type: "button",
597
+ role: "menuitem",
598
+ "aria-label": label,
599
+ "data-deck-studio-open": "",
600
+ disabled: busy,
601
+ style: {
602
+ width: "100%",
603
+ minHeight: 32,
604
+ padding: "0 12px",
605
+ fontSize: 13,
606
+ display: "flex",
607
+ alignItems: "center",
608
+ justifyContent: "flex-start",
609
+ textAlign: "left",
610
+ border: 0,
611
+ background: "transparent",
612
+ color: "var(--dsw-alias-label-primary)",
613
+ borderRadius: "var(--dsw-radius-sm)",
614
+ cursor: busy ? "default" : "pointer",
615
+ },
616
+ onClick: onSelect,
617
+ },
618
+ label,
619
+ );
620
+ }
621
+
426
622
  const currentSessionId = () => {
427
623
  try {
428
624
  const raw = window.localStorage.getItem("dsh.sessions.current");
@@ -435,7 +631,20 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
435
631
  const sessions = ctx.sessions;
436
632
  if (!sessions?.scope) throw new Error("sessions service unavailable");
437
633
  if (!sessionId) throw new Error("no target session");
438
- const scoped = sessions.scope(sessionId);
634
+ /**
635
+ * 0.2.20:刚建出来的会话还没被 Dock retain 过,`sessions.scope()` 就是 null——
636
+ * 以前直接抛 `session … is not retained`,于是「新建 deck 后点发送」整条链路失败:
637
+ * 会话建出来了却没绑定、工作区也不弹。这里先自己 retain 一次再取 scope。
638
+ */
639
+ let scoped = sessions.scope(sessionId);
640
+ if (!scoped && typeof sessions.retain === "function") {
641
+ try {
642
+ sessions.retain(sessionId, { source: WORKBENCH_KEY });
643
+ } catch (error) {
644
+ ctx.logger?.warn?.(`deck-studio: retain before send failed: ${error?.message ?? error}`);
645
+ }
646
+ scoped = sessions.scope(sessionId);
647
+ }
439
648
  if (!scoped) throw new Error(`session ${sessionId} is not retained`);
440
649
  // 会话级上下文需要显式 inject 才能拿到 conversation 服务。
441
650
  await new Promise((resolve, reject) => {
@@ -545,6 +754,28 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
545
754
  const sourceRoot = String(data.sourceRoot ?? "").trim();
546
755
  const sourceWorkspace = sourceRoot ? containing(sourceRoot) : null;
547
756
  if (sourceWorkspace) return sourceWorkspace.workspaceId;
757
+ /**
758
+ * 0.2.16:deck 自己的项目目录(工作台载荷里的 sessionWorkspacePath)。
759
+ * 桌面宿主里上面几级会全落空——cwd 是 profile 目录被守卫挡掉、源目录还没登记成工作区、
760
+ * 客户端 workspaces 服务也没有 initializeDefault——结果是「从没有会话的 deck」
761
+ * 永远建不出会话、也就永远绑不上(点了 + 没反应)。这里按 deck 项目目录建/复用工作区,
762
+ * 保证每本 deck 都有落点;工作区名只是侧栏文案,命名失败不影响建会话。
763
+ */
764
+ const projectPath = String(data.sessionWorkspacePath ?? "").trim();
765
+ if (projectPath) {
766
+ const existing = containing(projectPath);
767
+ if (existing) return existing.workspaceId;
768
+ const projectWorkspaceId = await register(projectPath);
769
+ const title = String(data.title ?? "").trim().slice(0, 60);
770
+ if (title && typeof workspaces.rename === "function") {
771
+ try {
772
+ await workspaces.rename(projectWorkspaceId, `Deckline · ${title}`);
773
+ } catch {
774
+ /* 工作区重名等失败只影响侧栏文案,会话照建 */
775
+ }
776
+ }
777
+ return projectWorkspaceId;
778
+ }
548
779
  const fallback = await workspaces.initializeDefault?.();
549
780
  if (fallback?.workspaceId) return fallback.workspaceId;
550
781
  throw new Error("无法确定启动工作区,未创建会话;请配置 workspacePath 后重试");
@@ -614,10 +845,18 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
614
845
  if (lastError) throw lastError;
615
846
  if (data.takeMain === true && DESKTOP_SHELL) closeWorkbench();
616
847
  }
848
+ let notice = "";
617
849
  if (data.prompt && data.action === "ensure" && sessionId !== String(data.sessionId ?? "")) {
618
- await sendToSession(sessionId, String(data.prompt));
850
+ // 0.2.20:首条指令发不出去不该让整条 ensure 失败——会话已经建好,
851
+ // 绑定必须照写(否则 deck 变「未绑定」,工作区也不会自动弹出来)。
852
+ try {
853
+ await sendToSession(sessionId, String(data.prompt));
854
+ } catch (error) {
855
+ notice = `会话已建好并绑定,但首条指令没发出去:${error?.message ?? error};在工作台里再发一次即可。`;
856
+ ctx.logger?.warn?.(`deck-studio: prompt send failed after ensure: ${error?.message ?? error}`);
857
+ }
619
858
  }
620
- reply({ ok: true, sessionId });
859
+ reply({ ok: true, sessionId, ...(notice ? { notice } : {}) });
621
860
  } catch (error) {
622
861
  ctx.logger?.warn?.("deck-studio: conversation bridge failed", error);
623
862
  reply({ ok: false, reason: String(error?.message ?? error) });
@@ -720,6 +959,78 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
720
959
  return () => window.clearTimeout(timer);
721
960
  }, "deck-studio: auto-open workbench");
722
961
 
962
+ /**
963
+ * 0.2.17:切到「已绑定 deck / design 的会话」时自动拉起工作区。
964
+ * 只在会话真的换了、且主区没有停在别的插件面板时动作;启动时先只记住当前会话不抢视图,
965
+ * 工作台自己打开的那一下不算用户切换(pendingOpen / 自身面板判定都挡掉)。
966
+ */
967
+ ctx.effect(() => {
968
+ const store = ctx.sessions?.list;
969
+ if (!store?.subscribe) return undefined;
970
+ let primed = false;
971
+ let lastActive = "";
972
+ let timer = null;
973
+ let token = 0;
974
+ const onChange = () => {
975
+ const list = store.getSnapshot?.();
976
+ if (!list || list.phase !== "ready") return;
977
+ const active = activeMainSessionId();
978
+ if (!primed) {
979
+ primed = true;
980
+ lastActive = active;
981
+ return;
982
+ }
983
+ if (active === lastActive) return;
984
+ lastActive = active;
985
+ if (!active) return;
986
+ // 会话变化的那一刻取面板:宿主随后会 selectPanel(null),晚一步就分不清原来停在哪。
987
+ const panelAtChange = activePanelIdOf();
988
+ const mine = ++token;
989
+ window.clearTimeout(timer);
990
+ timer = window.setTimeout(() => {
991
+ if (mine !== token || activeMainSessionId() !== active) return;
992
+ if (panelAtChange !== null && panelAtChange !== WORKBENCH_KEY) return;
993
+ const panel = activePanelIdOf();
994
+ if (panel !== null && panel !== WORKBENCH_KEY) return;
995
+ void openSessionInWorkbench(active, { auto: true }).catch((error) =>
996
+ ctx.logger?.warn?.(`deck-studio: auto-open bound session failed: ${error?.message ?? error}`));
997
+ }, 260);
998
+ };
999
+ const dispose = store.subscribe(onChange);
1000
+ onChange();
1001
+ return () => {
1002
+ window.clearTimeout(timer);
1003
+ token += 1;
1004
+ if (typeof dispose === "function") dispose();
1005
+ };
1006
+ }, "deck-studio: session → workbench");
1007
+
1008
+ // 工作台 iframe 就绪:把还没送出去的「打开某本 deck」补发一次。
1009
+ ctx.effect(() => {
1010
+ const onReady = (event) => {
1011
+ if (event.data?.type !== "dsh-deck-studio:workbench-ready") return;
1012
+ if (event.source !== (frameState.el?.contentWindow ?? null)) return;
1013
+ flushPendingOpen();
1014
+ };
1015
+ window.addEventListener("message", onReady);
1016
+ return () => window.removeEventListener("message", onReady);
1017
+ }, "deck-studio: workbench ready bridge");
1018
+
1019
+ // 工作台确认收到「打开某本 deck」→ 才算送达,清掉 pending(没 ack 就继续重试)。
1020
+ ctx.effect(() => {
1021
+ const onAck = (event) => {
1022
+ const data = event.data ?? {};
1023
+ if (data.type !== "dsh-deck-studio:open-binding-ack") return;
1024
+ if (event.source !== (frameState.el?.contentWindow ?? null)) return;
1025
+ if (!pendingOpen || String(data.id ?? "") !== String(pendingOpen.id ?? "")) return;
1026
+ const failed = data.ok === false;
1027
+ pendingOpen = null;
1028
+ if (failed) ctx.logger?.warn?.(`deck-studio: open binding failed: ${data.reason ?? "unknown"}`);
1029
+ };
1030
+ window.addEventListener("message", onAck);
1031
+ return () => window.removeEventListener("message", onAck);
1032
+ }, "deck-studio: open-binding ack bridge");
1033
+
723
1034
  // 唯一入口:侧栏面板行「Deckline」(与 plugins / schedules 等插件同一种形态,带图标);
724
1035
  // → 对话用工作台里的「← 回到对话」。
725
1036
  if (hasRightbar) {
@@ -729,6 +1040,21 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
729
1040
  ctx.slots.register({ name: "sidebar.panellist", id: WORKBENCH_KEY, order: 46, label: () => "Deckline" }, DeckGlyph)),
730
1041
  "deck-studio: workbench sidebar entry",
731
1042
  );
1043
+ // 会话行「⋯」菜单:把当前会话绑定的 deck 在工作台里打开(未绑定给可见提示)。
1044
+ // 该槽位是宿主可选契约,旧版本没有时只记一条警告,不能影响面板本身注册。
1045
+ try {
1046
+ ctx.effect(
1047
+ () => ctx.slots.inject("sidebar.workspaces.session.menu.item", () =>
1048
+ ctx.slots.register({
1049
+ name: "sidebar.workspaces.session.menu.item",
1050
+ id: "dsh-plugin-deck-studio.open-binding",
1051
+ order: 150,
1052
+ }, OpenDecklineMenuItem)),
1053
+ "deck-studio: session menu entry",
1054
+ );
1055
+ } catch (error) {
1056
+ ctx.logger?.warn?.(`deck-studio: session menu slot unavailable: ${error?.message ?? error}`);
1057
+ }
732
1058
  }
733
1059
  ctx.logger?.info?.("deck-studio client: workbench-only mode");
734
1060
  } else {