@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.3

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/lib/index.js CHANGED
@@ -2162,6 +2162,25 @@ var HostTaskLedger = class {
2162
2162
  for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
2163
2163
  return count;
2164
2164
  }
2165
+ /**
2166
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
2167
+ * undefined when no schedule is armed in the future. This is the instant the
2168
+ * Host arms its native timer at, so the board wakes exactly when work is due
2169
+ * instead of polling a fixed heartbeat.
2170
+ * @param now - current Host time in ms epoch.
2171
+ * @returns the nearest future trigger, or undefined when none is armed.
2172
+ */
2173
+ nextArmedRunAt(now) {
2174
+ let nearest;
2175
+ for (const task of this.document.tasks) {
2176
+ if (task.archivedAt !== void 0) continue;
2177
+ const schedule = task.schedule;
2178
+ if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0) continue;
2179
+ if (schedule.nextRunAt <= now) continue;
2180
+ if (nearest === void 0 || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt;
2181
+ }
2182
+ return nearest;
2183
+ }
2165
2184
  /** Return value-only references for schedules due at the supplied Host time. */
2166
2185
  dueSchedules(now) {
2167
2186
  const due = [];
@@ -3567,19 +3586,55 @@ var PowerInhibitor = class {
3567
3586
  };
3568
3587
  //#endregion
3569
3588
  //#region src/host-service.ts
3589
+ /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
3570
3590
  const SESSION_POLL_MS = 5e3;
3571
- const SCHEDULE_TICK_MS = 3e4;
3572
- const RESUME_GAP_MS = 45e3;
3591
+ /**
3592
+ * How late an armed schedule fire may be before it counts as a resume rather
3593
+ * than a normal occurrence. The schedule timer is armed AT the next due
3594
+ * instant, so landing this far past its target means the Host was suspended,
3595
+ * the process throttled, or the wall clock jumped forward — the same condition
3596
+ * the old fixed 30 s heartbeat detected through its own gap threshold.
3597
+ */
3598
+ const RECOVERY_TOLERANCE_MS = 6e4;
3599
+ /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
3600
+ const MAX_TIMER_DELAY_MS = 2147483647;
3601
+ /**
3602
+ * Actions that can move an armed trigger. Only these re-arm the native timer;
3603
+ * an unrelated card edit leaves the pending fire untouched.
3604
+ */
3605
+ const SCHEDULE_WRITE_ACTIONS = /* @__PURE__ */ new Set([
3606
+ "set-schedule",
3607
+ "delete",
3608
+ "archive"
3609
+ ]);
3610
+ /** Process-global fallback used when the deployment serves no cordis timer service. */
3611
+ const PROCESS_TIMERS = {
3612
+ timeout(callback, delay) {
3613
+ const handle = setTimeout(callback, delay);
3614
+ return () => {
3615
+ clearTimeout(handle);
3616
+ };
3617
+ },
3618
+ interval(callback, delay) {
3619
+ const handle = setInterval(callback, delay);
3620
+ return () => {
3621
+ clearInterval(handle);
3622
+ };
3623
+ }
3624
+ };
3573
3625
  var TaskBoardHostService = class {
3574
3626
  ledger;
3575
3627
  runner;
3576
3628
  power;
3577
3629
  listeners = /* @__PURE__ */ new Set();
3578
- timers = [];
3579
- lastScheduleTick;
3630
+ /** The one recurring timer: the session-roster poll. */
3631
+ pollTimer;
3632
+ /** The armed schedule timer, if a trigger is pending. */
3633
+ scheduleTimer;
3634
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
3635
+ scheduleTarget;
3580
3636
  disposed = false;
3581
3637
  pollInFlight = false;
3582
- tickInFlight = false;
3583
3638
  active = true;
3584
3639
  /**
3585
3640
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -3590,6 +3645,7 @@ var TaskBoardHostService = class {
3590
3645
  idleSessionIds;
3591
3646
  preventIdleSleep = false;
3592
3647
  team;
3648
+ timers;
3593
3649
  lastPowerJson = "";
3594
3650
  now;
3595
3651
  constructor(gateway, options = {}) {
@@ -3599,6 +3655,7 @@ var TaskBoardHostService = class {
3599
3655
  });
3600
3656
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
3601
3657
  this.team = options.team;
3658
+ this.timers = options.timers ?? PROCESS_TIMERS;
3602
3659
  this.power = options.power ?? new PowerInhibitor();
3603
3660
  this.now = options.now ?? Date.now;
3604
3661
  installStreamErrorGuards();
@@ -3614,16 +3671,13 @@ var TaskBoardHostService = class {
3614
3671
  });
3615
3672
  }
3616
3673
  start() {
3617
- if (this.disposed || this.timers.length > 0) return;
3674
+ if (this.disposed || this.pollTimer !== void 0) return;
3618
3675
  this.syncPowerReasons();
3619
- this.timers.push(setInterval(() => {
3676
+ this.pollTimer = this.timers.interval(() => {
3620
3677
  this.schedulePoll();
3621
- }, SESSION_POLL_MS));
3622
- this.timers.push(setInterval(() => {
3623
- this.scheduleTick(false);
3624
- }, SCHEDULE_TICK_MS));
3678
+ }, SESSION_POLL_MS);
3625
3679
  this.schedulePoll();
3626
- this.scheduleTick(true);
3680
+ this.recoverSchedule();
3627
3681
  }
3628
3682
  setConfiguration(active, preventIdleSleep) {
3629
3683
  const resumed = !this.active && active;
@@ -3640,8 +3694,8 @@ var TaskBoardHostService = class {
3640
3694
  this.power.setEnabled(active && preventIdleSleep);
3641
3695
  if (resumed) {
3642
3696
  this.schedulePoll();
3643
- this.scheduleTick(true);
3644
- }
3697
+ this.recoverSchedule();
3698
+ } else if (!active) this.clearScheduleTimer();
3645
3699
  this.emit();
3646
3700
  }
3647
3701
  snapshot() {
@@ -3679,6 +3733,7 @@ var TaskBoardHostService = class {
3679
3733
  }
3680
3734
  const result = this.ledger.applyRequest(requestId, action, initiator);
3681
3735
  if (result.runs !== void 0) this.dispatchRuns(result.runs);
3736
+ if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule();
3682
3737
  return {
3683
3738
  schemaVersion: 3,
3684
3739
  revision: result.state.revision,
@@ -3689,7 +3744,9 @@ var TaskBoardHostService = class {
3689
3744
  }
3690
3745
  dispose() {
3691
3746
  this.disposed = true;
3692
- for (const timer of this.timers.splice(0)) clearInterval(timer);
3747
+ this.clearScheduleTimer();
3748
+ this.pollTimer?.();
3749
+ this.pollTimer = void 0;
3693
3750
  this.power.dispose();
3694
3751
  this.ledger.dispose();
3695
3752
  this.listeners.clear();
@@ -3785,20 +3842,67 @@ var TaskBoardHostService = class {
3785
3842
  } catch {}
3786
3843
  }
3787
3844
  }
3788
- async tickSchedule(first) {
3845
+ /** Drop the armed schedule timer and forget its target. */
3846
+ clearScheduleTimer() {
3847
+ this.scheduleTimer?.();
3848
+ this.scheduleTimer = void 0;
3849
+ this.scheduleTarget = void 0;
3850
+ }
3851
+ /**
3852
+ * Boot / resume recovery: skip every occurrence that came due while the
3853
+ * board was not running and roll each schedule to its next future target,
3854
+ * then arm the timer for the nearest one. Rendering the occurrence is
3855
+ * deliberately not attempted: the ACL of a card that fired hours ago is
3856
+ * stale, and the board's own recovery contract is "missed triggers are
3857
+ * skipped, never replayed".
3858
+ */
3859
+ recoverSchedule() {
3860
+ if (this.disposed) return;
3861
+ this.clearScheduleTimer();
3862
+ const now = this.now();
3863
+ this.ledger.setScheduler({ lastTickAt: now });
3864
+ this.ledger.skipMissed(now);
3865
+ this.armSchedule();
3866
+ }
3867
+ /**
3868
+ * Arm the native timer at the nearest armed future trigger. One timer serves
3869
+ * every schedule: the ledger's next target is the only instant the Host has
3870
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
3871
+ * segments, and a target already past (the wall clock jumped, or the process
3872
+ * was suspended) is handled immediately as a recovery.
3873
+ */
3874
+ armSchedule() {
3875
+ if (this.disposed || !this.active) return;
3876
+ this.clearScheduleTimer();
3877
+ const target = this.ledger.nextArmedRunAt(this.now());
3878
+ if (target === void 0) return;
3879
+ this.scheduleTarget = target;
3880
+ const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS));
3881
+ this.scheduleTimer = this.timers.timeout(() => {
3882
+ this.scheduleTimer = void 0;
3883
+ this.onScheduleFire(target);
3884
+ }, delay);
3885
+ }
3886
+ /**
3887
+ * One armed target became due. A fire landing well past its target is a
3888
+ * resume (suspend, throttle, forward clock jump) rather than a normal
3889
+ * occurrence, so it takes the recovery path instead of launching a run for a
3890
+ * long-stale instant.
3891
+ */
3892
+ onScheduleFire(target) {
3789
3893
  if (this.disposed || !this.active) return;
3790
3894
  const now = this.now();
3791
- const recovered = first || this.lastScheduleTick !== void 0 && now - this.lastScheduleTick > RESUME_GAP_MS;
3792
- this.lastScheduleTick = now;
3895
+ this.scheduleTarget = void 0;
3793
3896
  this.ledger.setScheduler({ lastTickAt: now });
3794
- if (recovered) {
3795
- this.ledger.skipMissed(now);
3897
+ if (now - target > RECOVERY_TOLERANCE_MS) {
3898
+ this.recoverSchedule();
3796
3899
  return;
3797
3900
  }
3798
3901
  for (const schedule of this.ledger.dueSchedules(now)) {
3799
3902
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
3800
3903
  this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now));
3801
3904
  }
3905
+ this.armSchedule();
3802
3906
  }
3803
3907
  armedSchedules() {
3804
3908
  return this.ledger.armedScheduleCount();
@@ -3831,14 +3935,18 @@ var TaskBoardHostService = class {
3831
3935
  this.pollInFlight = false;
3832
3936
  });
3833
3937
  }
3834
- scheduleTick(first) {
3835
- if (this.tickInFlight || this.disposed) return;
3836
- this.tickInFlight = true;
3837
- this.tickSchedule(first).catch((error) => {
3838
- safeConsoleError("[dsh-task-board] scheduler tick failed", error);
3839
- }).finally(() => {
3840
- this.tickInFlight = false;
3841
- });
3938
+ /**
3939
+ * Re-arm from the ledger's current targets. Callers that just changed a
3940
+ * schedule (the host routes, the agent tools) invoke this after the write
3941
+ * commits, so a new or edited trigger arms without waiting for the next fire.
3942
+ */
3943
+ refreshSchedule() {
3944
+ if (this.disposed) return;
3945
+ if (!this.active) {
3946
+ this.clearScheduleTimer();
3947
+ return;
3948
+ }
3949
+ this.armSchedule();
3842
3950
  }
3843
3951
  syncPowerReasons() {
3844
3952
  const current = this.power.snapshot();
@@ -4895,13 +5003,47 @@ function resolveAccess(access) {
4895
5003
  };
4896
5004
  }
4897
5005
  /**
5006
+ * Harness browser-auth cookie prefix (dsh-client-connection): the
5007
+ * authority-bound signed cookie the Host mints in exchange for its launch
5008
+ * token. dsh-web's remote channel redeems that token before re-issuing paired
5009
+ * traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
5010
+ * official DSH Desktop shell keeps its own copy from the same exchange and
5011
+ * attaches it to every request it forwards for the dsh-app://app/ page. The
5012
+ * two constants describe one fact and move together.
5013
+ */
5014
+ const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
5015
+ /**
5016
+ * Whether a Cookie header carries the Host's browser-auth credential. Only an
5017
+ * application on this machine can hold it: the desktop shell never lets it
5018
+ * reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
5019
+ * from attaching it.
5020
+ * @param header - the raw Cookie header value.
5021
+ */
5022
+ function carriesBrowserAuthCookie(header) {
5023
+ if (header === void 0) return false;
5024
+ return header.split(";").some((segment) => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX));
5025
+ }
5026
+ /**
4898
5027
  * Browser-signal tripwire, NOT an authority check: a bare curl sends neither
4899
5028
  * header and is refused, but a curl with a forged Origin passes this too.
4900
5029
  * The real boundary is the loopback socket + Host + origin-equality checks
4901
5030
  * in isTrustedTaskBoardRequest below; do not rely on this marker alone.
5031
+ *
5032
+ * A first-party client that presents NEITHER header must still pass. The DSH
5033
+ * Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
5034
+ * page's Host requests itself, deleting origin and sec-fetch-site on the
5035
+ * way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
5036
+ * arrives marker-less and every route behind this guard answered 403 - which
5037
+ * the panel renders as its board.hostError.unauthorized message. The shell
5038
+ * does attach the Host's browser-auth cookie, redeemed from the Host's launch
5039
+ * URL at startup and deliberately withheld from the page. That credential,
5040
+ * not a header the shell strips, is the browser signal of an application on
5041
+ * this machine; a marker-less, credential-less request stays refused.
4902
5042
  */
4903
5043
  function browserSameOriginMarker(req) {
4904
- return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
5044
+ if (req.headers["sec-fetch-site"] === "same-origin") return true;
5045
+ if (typeof req.headers.origin === "string") return true;
5046
+ return carriesBrowserAuthCookie(req.headers.cookie);
4905
5047
  }
4906
5048
  function sameAuthority(req, host) {
4907
5049
  if (req.headers["sec-fetch-site"] === "cross-site") return false;
@@ -5113,38 +5255,111 @@ function makeTaskBoardRoutes(service, access = {}, options = {}) {
5113
5255
  * the loader accepts a standalone install of the same package side by side;
5114
5256
  * without this guard the second instance would still re-register the same
5115
5257
  * webserver routes, tools, settings namespaces, and system-prompt sections
5116
- * and fail the boot. mountOnce makes the second host apply a no-op for the
5117
- * lifetime of the first instance (the browser half is already deduped by
5118
- * package name in the client module host).
5258
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
5259
+ * no-op for as long as the first instance lives (the browser half is already
5260
+ * deduped by package name in the client module host).
5261
+ *
5262
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
5263
+ * long after the refused mount ran its course: the Host reloads a profile by
5264
+ * creating the new loader entries before the old ones are torn down (a
5265
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
5266
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
5267
+ * previous entry still owns the name. Dropping that refused mount lost the
5268
+ * plugin for good - the previous entry then disposed its own mount, releasing
5269
+ * the name with nobody left to take it, and the family row stayed listed as
5270
+ * active while its host routes 404ed (the task-board panel showed
5271
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
5272
+ * restart recovered it).
5273
+ *
5274
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
5275
+ * the holder releases the name - if the waiting fiber is still alive then. The
5276
+ * single-instance guarantee is unchanged: exactly one mount is live per
5277
+ * package name, and the replay re-enters the guard so a later mount still
5278
+ * dedupes against it.
5119
5279
  *
5120
5280
  * The registry rides a global symbol so two module instances of the same
5121
- * package (npm copy vs repository link) still share one verdict. cordis
5122
- * `ctx.effect` runs its callback immediately and treats the callback's
5281
+ * package (npm copy vs repository link) still share one verdict. That symbol
5282
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
5283
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
5284
+ * dsh-community-plugins) are separate repositories carrying their own copy of
5285
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
5286
+ * must keep the shape every published copy reads (a `Set` of package names
5287
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
5288
+ * under their own additive key, and the registry reads back a `Set` even when
5289
+ * some other build left a different value there. Changing `MOUNTED`'s shape
5290
+ * in place broke that contract once: a satellite's legacy copy created a
5291
+ * `Set`, the family's new copy read it as a `Map`, and every family row
5292
+ * mounted after it failed with "claims.get is not a function".
5293
+ *
5294
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
5123
5295
  * return value as the fiber disposer, so the unmarker is returned, not run.
5124
5296
  */
5297
+ /** Published cross-repository contract: package names currently mounted. */
5125
5298
  const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
5299
+ /** Additive key this guard owns: refused mounts waiting for the name. */
5300
+ const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
5301
+ /**
5302
+ * The shared name registry, always a `Set` whatever another build stored here:
5303
+ * a foreign value (an interim shape, a hand-written global) must not take every
5304
+ * family plugin down with it.
5305
+ * @returns the process-wide set of mounted package names.
5306
+ */
5126
5307
  function mountedSet() {
5127
5308
  const registry = globalThis;
5128
- return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
5309
+ const existing = registry[MOUNTED];
5310
+ if (existing instanceof Set) return existing;
5311
+ const created = /* @__PURE__ */ new Set();
5312
+ registry[MOUNTED] = created;
5313
+ return created;
5314
+ }
5315
+ /** Queue per package name for mounts refused while a holder was alive. */
5316
+ function mountWaiters() {
5317
+ const registry = globalThis;
5318
+ return registry[WAITERS] ??= /* @__PURE__ */ new Map();
5129
5319
  }
5130
5320
  /**
5131
5321
  * Wrap a cordis plugin apply so the package runs at most once per process.
5132
- * The first mount registers normally and unmarks when its fiber disposes;
5133
- * any later mount of the same package name is a no-op.
5322
+ * The first mount registers normally and releases the name when its fiber
5323
+ * disposes; a mount refused while that name is held waits for the release and
5324
+ * then runs, unless its own fiber disposes first.
5134
5325
  * @param packageName - npm package identity shared by every install source.
5135
5326
  * @param fn - the original plugin apply.
5136
5327
  * @returns an apply of the same shape.
5137
5328
  */
5138
5329
  function mountOnce(packageName, fn) {
5139
- return ((...args) => {
5330
+ const mount = (...args) => {
5140
5331
  const mounted = mountedSet();
5141
- if (mounted.has(packageName)) return;
5332
+ const ctx = args[0];
5333
+ if (mounted.has(packageName)) {
5334
+ const waiters = mountWaiters();
5335
+ const queue = waiters.get(packageName) ?? [];
5336
+ let alive = true;
5337
+ const pending = { run: () => {
5338
+ if (alive) mount(...args);
5339
+ } };
5340
+ ctx?.effect?.(() => () => {
5341
+ alive = false;
5342
+ const index = queue.indexOf(pending);
5343
+ if (index >= 0) queue.splice(index, 1);
5344
+ });
5345
+ queue.push(pending);
5346
+ waiters.set(packageName, queue);
5347
+ return;
5348
+ }
5142
5349
  mounted.add(packageName);
5143
- args[0]?.effect?.(() => () => {
5350
+ ctx?.effect?.(() => () => {
5144
5351
  mounted.delete(packageName);
5352
+ const waiters = mountWaiters();
5353
+ const queue = waiters.get(packageName);
5354
+ if (queue === void 0) return;
5355
+ waiters.delete(packageName);
5356
+ for (const waiter of queue.splice(0)) queueMicrotask(() => {
5357
+ waiter.run();
5358
+ });
5145
5359
  });
5146
5360
  return fn(...args);
5147
- });
5361
+ };
5362
+ return mount;
5148
5363
  }
5149
5364
  //#endregion
5150
5365
  //#region src/index.ts
@@ -5228,6 +5443,27 @@ function resolveToolRegistry(ctx) {
5228
5443
  return;
5229
5444
  }
5230
5445
  }
5446
+ /**
5447
+ * Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
5448
+ * row). The board arms its schedule through it so unloading the row clears
5449
+ * every pending timer with the owning fiber, instead of the row tracking raw
5450
+ * process handles by hand. A composition that serves no timer service keeps
5451
+ * working on the process globals.
5452
+ * @param ctx - the plugin context.
5453
+ * @returns the timer face, or undefined when this deployment serves none.
5454
+ */
5455
+ function resolveHostTimers(ctx) {
5456
+ try {
5457
+ const timer = ctx.get("timer");
5458
+ if (timer === void 0) return void 0;
5459
+ return typeof timer.timeout === "function" && typeof timer.interval === "function" ? {
5460
+ timeout: timer.timeout.bind(timer),
5461
+ interval: timer.interval.bind(timer)
5462
+ } : void 0;
5463
+ } catch {
5464
+ return;
5465
+ }
5466
+ }
5231
5467
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
5232
5468
  const DEFAULT_ANNOUNCE = false;
5233
5469
  /**
@@ -5331,6 +5567,7 @@ function applyImpl(ctx, config) {
5331
5567
  workspaceRegistry: ctx.workspaceRegistry,
5332
5568
  sessionDefaultPermission: config?.sessionDefaultPermission ?? "read-only",
5333
5569
  maxSubtaskDepth: maxSubtaskDepth(),
5570
+ timers: resolveHostTimers(ctx),
5334
5571
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? "spawn"),
5335
5572
  commandDispatcher: { async execute(sessionId, line, signal) {
5336
5573
  const agent = ctx.agents.get(sessionId);
@@ -5416,4 +5653,4 @@ function applyImpl(ctx, config) {
5416
5653
  sync();
5417
5654
  }
5418
5655
  //#endregion
5419
- export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
5656
+ export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
@@ -9,7 +9,7 @@
9
9
  * plugin must not take the GUI down.
10
10
  */
11
11
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
12
- import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
12
+ import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client';
13
13
  import { type TaskBoardSettings } from './TaskBoardSettingsCard.tsx';
14
14
  import { type TaskBoardKey } from './locales.ts';
15
15
  /** Domain-owned description of one settings namespace a family card binds. */
@@ -94,3 +94,17 @@ export declare function apply(ctx: ClientContext): void;
94
94
  * @returns the form the settings card reads and writes.
95
95
  */
96
96
  export declare function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettings>;
97
+ /**
98
+ * The profile entry id this package's own row carries, for a page that serves
99
+ * no family binder.
100
+ *
101
+ * The shared describe mirror is the only local evidence of which row id this
102
+ * profile actually serves, but it answers asynchronously: at plugin activation
103
+ * it usually holds nothing yet. An unanswered mirror therefore binds the
104
+ * aggregate row id rather than guessing among the candidates, and the binding
105
+ * is re-resolved once the mirror answers — see
106
+ * {@link createServedEntryForm}, which owns that rebinding.
107
+ * @param forms - the shared configuration forms service.
108
+ * @returns the entry id to bind before the mirror answers.
109
+ */
110
+ export declare function servedEntryId(forms: ConfigForms): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Native panel registration for the task board.
3
+ *
4
+ * The board is an official-style center-column panel, not a DOM takeover: it
5
+ * contributes a row into the sidebar shell's own global panel list
6
+ * (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
7
+ * shell then owns the row box, the label, the active highlight, the collapsed
8
+ * rail, the panel switch and the window-chrome interplay exactly as it does for
9
+ * the shipped Plugins and Schedule pages, so the board renders through the same
10
+ * container the desktop application's Plugins page uses.
11
+ *
12
+ * Both registrations go through `ctx.slots.inject`, which fires only once the
13
+ * owning shell entry has declared the seat: load order between this plugin and
14
+ * ui-layout / ui-sidebar therefore does not matter, and neither does a shell
15
+ * that cannot serve the seats (the callback simply never runs).
16
+ *
17
+ * @module @linxin666/dsh-client-ui-task-board/client
18
+ */
19
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
20
+ import { TASK_BOARD_PANEL_ID, type BoardController } from '../core/controller.ts';
21
+ /** The panel id shared by the sidebar row and the main-slot occupant. */
22
+ export { TASK_BOARD_PANEL_ID };
23
+ /**
24
+ * The sidebar row glyph the shell asks for at its own size and active state.
25
+ * The shell owns the button, label, tooltip and rail geometry; this component
26
+ * draws only the glyph, like every other panel row.
27
+ *
28
+ * The glyph carries `data-dsh-panel-entry` because it is the only DOM the
29
+ * panel's own code owns inside that shell-owned row: the L2 contract (skins)
30
+ * resolves which row belongs to which plugin through it, since the shell
31
+ * stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
32
+ * @param props - the shell's icon share: square edge and selection state.
33
+ * @returns the decorative board glyph.
34
+ */
35
+ export declare function TaskBoardPanelIcon({ size }: {
36
+ size: number;
37
+ active: boolean;
38
+ }): React.ReactElement;
39
+ /**
40
+ * The main-slot page. The layout mounts it only while the board is the selected
41
+ * panel, so the conversation keeps the center column untouched the rest of the
42
+ * time; the wrapper carries the pinned `data-dsh-taskboard-view` semantic
43
+ * anchor (L2 contract, skins) and the container query context the board's
44
+ * responsive rules read.
45
+ * @param props - the framework main-slot share plus this entry's injected face.
46
+ * @returns the board page.
47
+ */
48
+ export declare function TaskBoardPanel({ controller }: {
49
+ controller: BoardController;
50
+ }): React.ReactElement;
51
+ /**
52
+ * Register the board's sidebar row and center-column page.
53
+ *
54
+ * Both seats are declared by shell plugins this package does not depend on at
55
+ * runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
56
+ * runs only after the owning entry declares the seat, and a shell that never
57
+ * declares it leaves the board simply absent instead of failing boot.
58
+ * @param ctx - client root context (services: slots).
59
+ * @param controller - the board controller the page and the row drive.
60
+ * @returns disposer releasing both registrations.
61
+ */
62
+ export declare function registerTaskBoardPanel(ctx: ClientContext, controller: BoardController): () => void;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Family settings form bound to the profile entry id a Host actually serves.
3
+ *
4
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
5
+ * a family card knows only its own settings namespace. The family binder
6
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
7
+ * the other and is the preferred transport; this module is the fallback for a
8
+ * page that serves no binder — the group plugin is not installed, or its client
9
+ * half has not applied yet (the aggregate mounts its children in order, and a
10
+ * child still waiting on its own injects publishes nothing).
11
+ *
12
+ * The shared describe mirror is the only local evidence of which row id this
13
+ * profile serves, but it answers asynchronously: at plugin activation it usually
14
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
15
+ * deployment, and the form is bound once per session:
16
+ *
17
+ * - binding the bare namespace made every save answer `No configurable plugin
18
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
19
+ * id there), which the card reported as "the deployment did not accept these
20
+ * values";
21
+ * - binding the aggregate row id would break a standalone install, whose row id
22
+ * is the namespace or the package's own `ui-*` row.
23
+ *
24
+ * So the fallback starts on the most likely row and REBINDS the moment the
25
+ * mirror names one of this package's rows. Until the mirror answers, the form
26
+ * reports the binding it holds (usually `loading`), which the card renders as an
27
+ * unavailable form rather than a doomed one.
28
+ */
29
+ import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client';
30
+ /** Options of {@link createServedEntryForm}. */
31
+ export interface ServedEntryFormOptions<T> {
32
+ /** The shared configuration forms service (`ctx.configForms`). */
33
+ forms: ConfigForms;
34
+ /**
35
+ * Profile entry ids this package's rows carry, most likely first: the
36
+ * aggregate's generated row, the package's own standalone row, then the bare
37
+ * namespace. The first is the fallback while the mirror has no answer; the
38
+ * last is the answer when the mirror serves none of them.
39
+ */
40
+ entryIds: readonly string[];
41
+ }
42
+ /**
43
+ * Create a settings form bound to the profile entry id this Host serves, and
44
+ * rebound whenever the shared describe mirror names a different one of this
45
+ * package's rows.
46
+ * @param options - the shared forms service and this package's candidate row ids.
47
+ * @returns a form delegating to the currently resolved entry's own form.
48
+ */
49
+ export declare function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T>;
@@ -43,6 +43,19 @@ export interface SessionsControllerFace {
43
43
  /** Subscribe to catalog changes; selection ownership rides the same snapshot. */
44
44
  subscribe(fn: () => void): () => void;
45
45
  }
46
+ /** Stable id shared by the board's sidebar panel row and its main-slot page. */
47
+ export declare const TASK_BOARD_PANEL_ID = "task-board";
48
+ /**
49
+ * The layout's panel-navigation face. The board no longer owns the center
50
+ * column at the DOM level: selecting the panel is the layout's business, and
51
+ * this is the one call that asks for it (null returns to the conversation).
52
+ * Optional, so a composition without the layout service keeps the board's
53
+ * pure state transitions testable.
54
+ */
55
+ export interface PanelNavigationFace {
56
+ /** Select the board panel, or null to hand the column back to the conversation. */
57
+ select(panelId: string | null): void;
58
+ }
46
59
  /** Controller dependencies (all swappable in tests). */
47
60
  export interface ControllerDeps {
48
61
  store: TaskStore;
@@ -53,6 +66,8 @@ export interface ControllerDeps {
53
66
  uuid?: () => string;
54
67
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
55
68
  transport?: TaskBoardTransport;
69
+ /** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
70
+ panel?: PanelNavigationFace;
56
71
  }
57
72
  /**
58
73
  * Register one host directory as a DSH project (workspace). Wired by the
@@ -149,9 +164,33 @@ export declare class BoardController {
149
164
  isHostBacked(): boolean;
150
165
  /** Retry initial migration/state synchronization after an explicit Host error. */
151
166
  retryHostSync(): Promise<boolean>;
167
+ /**
168
+ * Show the board. The layout owns which panel the center column renders, so
169
+ * the state flip and the panel selection travel together here; a composition
170
+ * with no layout face (tests, a shell-less host) still flips the state.
171
+ *
172
+ * The selection is requested AFTER the snapshot flips so a subscriber
173
+ * rendering against `boardOpen` never observes "open" while the shell still
174
+ * shows the conversation.
175
+ */
152
176
  openBoard(): void;
177
+ /**
178
+ * Return the center column to the conversation. The layout's own selection is
179
+ * the source of truth for what the column renders, so this asks for the
180
+ * conversation explicitly rather than only clearing local state.
181
+ */
153
182
  closeBoard(): void;
154
183
  toggleBoard(): void;
184
+ /**
185
+ * Reflect a panel selection that came from OUTSIDE this controller (the user
186
+ * clicked another sidebar row, or the layout dropped the panel id). Keeps
187
+ * `boardOpen` aligned with what the column actually shows without asking the
188
+ * layout to select anything back.
189
+ * @param panelId - the layout's current panel id, or null for the conversation.
190
+ */
191
+ syncPanelSelection(panelId: string | null): void;
192
+ /** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
193
+ private selectPanel;
155
194
  /**
156
195
  * Switch between the kanban columns and the archive view. Leaving the
157
196
  * archive view with an archived task still selected closes the selection —
@@ -259,8 +298,8 @@ export declare class BoardController {
259
298
  * (background navigation, the Host runner creating and selecting a fresh
260
299
  * execution session, settlement, other plugins), so closing on `current`
261
300
  * changes would evict the board without the user asking. The board closes
262
- * only on explicit user navigation: a sidebar session/workspace row click
263
- * (board-mount onClickSidebarRow) or the board's own actions
301
+ * only on explicit user navigation: selecting another panel (the layout owns
302
+ * selection, and `syncPanelSelection` follows it) or the board's own actions
264
303
  * (openSession / close). Keeping the hook preserves the subscription
265
304
  * contract for future listeners.
266
305
  */