@fonlan/dsh-quick-commands 0.1.6 → 0.1.8

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
@@ -1,7 +1,141 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import z from "@deepseek-ai/schemastery";
6
+ //#region src/server/shell-path.ts
7
+ /**
8
+ * Login-shell PATH resolution for the local quick-command backend.
9
+ *
10
+ * `dsh web` started from a terminal inherits the user's full PATH, so a
11
+ * configured command such as `npm run typecheck` or `docker compose up -d`
12
+ * finds its toolchain. **DSH Desktop does not**: the Electron host is launched
13
+ * by launchd and runs with the minimal system PATH
14
+ * (`/usr/bin:/bin:/usr/sbin:/sbin`), which hides homebrew, `/usr/local/bin`,
15
+ * nvm, corepack and Docker Desktop. Every command that leans on the user's
16
+ * toolchain then fails with "command not found" in Desktop while the very same
17
+ * profile keeps working in a browser-launched host.
18
+ *
19
+ * This module asks the user's interactive login shell for its PATH once
20
+ * (cached for the process lifetime) and prepends it to the inherited PATH for
21
+ * local runs. Resolution is best effort by design: a missing `$SHELL`, a
22
+ * timeout, a non-zero exit or unparseable output all leave the inherited PATH
23
+ * untouched, so a terminal-launched host behaves exactly as before.
24
+ */
25
+ /** Delimiters wrapping the shell's own PATH in the probe's stdout. */
26
+ const PATH_START = "__DSH_QC_PATH_START__";
27
+ const PATH_END = "__DSH_QC_PATH_END__";
28
+ const defaultRunner = (file, args, options) => new Promise((resolve, reject) => {
29
+ execFile(file, args, options, (error, stdout) => {
30
+ if (error) reject(error);
31
+ else resolve({ stdout });
32
+ });
33
+ });
34
+ /**
35
+ * Extract the PATH a shell printed between the markers.
36
+ *
37
+ * Login profiles are free to print their own noise (nvm notices, version
38
+ * managers, MOTDs), so only the marked span counts — never the whole stdout.
39
+ * @param stdout - raw probe stdout.
40
+ * @returns the marked PATH, or '' when the markers are missing.
41
+ */
42
+ function extractShellPath(stdout) {
43
+ if (typeof stdout !== "string" && !(stdout instanceof Buffer)) return "";
44
+ const text = String(stdout);
45
+ const start = text.indexOf(PATH_START);
46
+ const end = text.lastIndexOf(PATH_END);
47
+ if (start < 0 || end < 0 || end < start) return "";
48
+ return text.slice(start + 21, end);
49
+ }
50
+ /**
51
+ * Put the login-shell PATH in front of the inherited one.
52
+ *
53
+ * The login PATH wins (that is where the user's toolchain lives); entries from
54
+ * the inherited PATH stay behind it as a fallback. Empty entries are dropped
55
+ * and duplicates keep their first position.
56
+ * @param loginPath - PATH reported by the login shell ('' when unavailable).
57
+ * @param inheritedPath - the host process's own PATH.
58
+ * @returns the merged PATH (the inherited value when there is no login one).
59
+ */
60
+ function mergePaths(loginPath, inheritedPath) {
61
+ const seen = /* @__PURE__ */ new Set();
62
+ const entries = [];
63
+ for (const part of `${loginPath}:${inheritedPath ?? ""}`.split(":")) {
64
+ const entry = part.trim();
65
+ if (entry === "" || seen.has(entry)) continue;
66
+ seen.add(entry);
67
+ entries.push(entry);
68
+ }
69
+ return entries.join(":");
70
+ }
71
+ /** The shell program whose login PATH describes this user's environment. */
72
+ function loginShell(env = process.env) {
73
+ const shell = env.SHELL?.trim();
74
+ return shell !== void 0 && shell !== "" ? shell : void 0;
75
+ }
76
+ /**
77
+ * Ask `shell` (interactive login) for its PATH.
78
+ *
79
+ * Both flags matter: `-l` sources the profile files that own the system PATH
80
+ * (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
81
+ * interactive config where nvm/fnm/volta and other version managers install
82
+ * their shims (this user's node lives in `~/.zshrc`). A hung or noisy
83
+ * interactive profile cannot block a run: the probe carries a deadline, and
84
+ * only the marked span of stdout is read.
85
+ * @param options.shell - shell program; defaults to `$SHELL`.
86
+ * @param options.timeoutMs - probe deadline.
87
+ * @param options.run - probe runner, injectable for tests.
88
+ * @param options.env - environment the probe itself runs with.
89
+ * @returns the login PATH, or '' when it cannot be determined.
90
+ */
91
+ async function resolveLoginShellPath(options = {}) {
92
+ if (process.platform === "win32") return "";
93
+ const env = options.env ?? process.env;
94
+ const shell = options.shell ?? loginShell(env);
95
+ if (shell === void 0) return "";
96
+ const run = options.run ?? defaultRunner;
97
+ const command = `printf '%s' "${PATH_START}\${PATH}${PATH_END}"`;
98
+ try {
99
+ const { stdout } = await run(shell, [
100
+ "-i",
101
+ "-l",
102
+ "-c",
103
+ command
104
+ ], {
105
+ timeout: options.timeoutMs ?? 3e3,
106
+ windowsHide: true,
107
+ env
108
+ });
109
+ const path = extractShellPath(stdout).trim();
110
+ return /[\s]/.test(path) ? "" : path;
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+ let cached;
116
+ /** Cached, process-wide login-shell PATH (one probe per host lifetime). */
117
+ function loginShellPath() {
118
+ cached ??= resolveLoginShellPath();
119
+ return cached;
120
+ }
121
+ /**
122
+ * Environment for one local quick command.
123
+ *
124
+ * A copy of the host environment with the login-shell PATH merged in front, so
125
+ * Desktop-launched hosts see the same toolchain a terminal-launched host does.
126
+ * @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
127
+ * @returns the spawn environment.
128
+ */
129
+ async function localCommandEnv(overrides = {}) {
130
+ const env = { ...process.env };
131
+ const loginPath = await loginShellPath();
132
+ if (loginPath !== "") env.PATH = mergePaths(loginPath, process.env.PATH);
133
+ return {
134
+ ...env,
135
+ ...overrides
136
+ };
137
+ }
138
+ //#endregion
5
139
  //#region src/server/remote.ts
6
140
  /**
7
141
  * Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
@@ -312,12 +446,11 @@ var QuickCommandRunner = class {
312
446
  stdout: { maxBytes: TAIL_MAX_BYTES },
313
447
  stderr: { maxBytes: TAIL_MAX_BYTES }
314
448
  },
315
- env: {
316
- ...process.env,
449
+ env: await localCommandEnv({
317
450
  DSH_WORKSPACE: input.workspacePath,
318
451
  DSH_WORKSPACE_ID: input.workspaceId,
319
452
  DSH_WORKSPACE_TITLE: input.workspaceTitle
320
- }
453
+ })
321
454
  });
322
455
  const live = {
323
456
  runId,
@@ -556,43 +689,67 @@ const WorkspaceCommandsSchema = z.object({
556
689
  commands: z.array(CommandSchema).max(200)
557
690
  });
558
691
  const QuickCommandsSettingsSchema = z.object({
559
- workspaces: z.array(WorkspaceCommandsSchema).max(500),
560
- popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner"),
692
+ workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
693
+ popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner").volatile(),
561
694
  popupSize: z.object({
562
695
  width: z.number().min(320).max(8192),
563
696
  height: z.number().min(200).max(8192)
564
- })
697
+ }).volatile()
565
698
  });
566
- const DEFAULT_SETTINGS = {
567
- workspaces: [],
568
- popupAnchor: "corner"
569
- };
570
- function registerSettings(ctx) {
571
- let current = {
572
- ...DEFAULT_SETTINGS,
573
- workspaces: []
699
+ /**
700
+ * Read one config field. A volatile field arrives as a handle (dsh >= 0.1.7)
701
+ * and is read through `.get()` on EVERY access, so edits made through the
702
+ * settings plane are visible immediately; hosts that hand the plain value keep
703
+ * working unchanged.
704
+ */
705
+ function readField(field, fallback) {
706
+ if (field === void 0 || field === null) return fallback;
707
+ const handle = field;
708
+ const value = typeof handle.get === "function" ? handle.get() : field;
709
+ return value === void 0 || value === null ? fallback : value;
710
+ }
711
+ function registerSettings(ctx, config) {
712
+ const readWorkspaces = () => {
713
+ const value = readField(config?.workspaces, []);
714
+ return Array.isArray(value) ? value : [];
715
+ };
716
+ const readAnchor = () => readField(config?.popupAnchor, "corner") === "button" ? "button" : "corner";
717
+ const readPopupSize = () => {
718
+ const value = readField(config?.popupSize, void 0);
719
+ if (typeof value !== "object" || value === null) return void 0;
720
+ const { width, height } = value;
721
+ if (typeof width !== "number" || typeof height !== "number") return void 0;
722
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return void 0;
723
+ return {
724
+ width: Math.round(width),
725
+ height: Math.round(height)
726
+ };
727
+ };
728
+ /** Current document, rebuilt from the live config fields on every read. */
729
+ const readDocument = () => {
730
+ const size = readPopupSize();
731
+ return {
732
+ workspaces: readWorkspaces(),
733
+ popupAnchor: readAnchor(),
734
+ ...size === void 0 ? {} : { popupSize: size }
735
+ };
574
736
  };
575
737
  let service;
576
738
  ctx.inject(["settings"], (sctx) => {
577
739
  service = sctx.settings;
578
- const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema);
579
- current = scope.get();
580
- scope.watch((next) => {
581
- current = next;
582
- });
583
740
  });
584
741
  async function persist(patch) {
585
742
  if (service === void 0) throw new Error("@fonlan/dsh-quick-commands: settings service is not available in this profile");
586
743
  await service.update(QUICK_COMMANDS_NS, patch);
587
744
  }
588
745
  return {
589
- get: () => current,
746
+ get: readDocument,
590
747
  commandsOf: (workspaceId) => {
591
- const set = current.workspaces.find((w) => w.workspaceId === workspaceId);
748
+ const set = readWorkspaces().find((w) => w.workspaceId === workspaceId);
592
749
  return set === void 0 ? [] : set.commands;
593
750
  },
594
751
  setCommands: async (workspaceId, commands) => {
595
- const next = current.workspaces.filter((w) => w.workspaceId !== workspaceId);
752
+ const next = readWorkspaces().filter((w) => w.workspaceId !== workspaceId);
596
753
  if (commands.length > 0) next.push({
597
754
  workspaceId,
598
755
  commands
@@ -837,10 +994,8 @@ function registerApiRoutes(ctx, runner, settings) {
837
994
  };
838
995
  }
839
996
  }
840
- const webServer = ctx.get("webServer");
841
- if (webServer === void 0) return () => void 0;
842
997
  return ctx.effect(() => {
843
- return webServer.register({
998
+ return ctx.webServer.register({
844
999
  kind: "prefix",
845
1000
  path: API_PREFIX,
846
1001
  handler: async (req, res) => {
@@ -871,33 +1026,32 @@ function registerApiRoutes(ctx, runner, settings) {
871
1026
  //#region src/index.ts
872
1027
  const name = "@fonlan/dsh-quick-commands";
873
1028
  const inject = ["settings"];
874
- /** No plugin-level config; all configuration lives in the settings namespace. */
875
- function apply(ctx) {
876
- const settings = registerSettings(ctx);
877
- const subprocess = ctx.get("subprocess");
878
- if (subprocess === void 0) {
879
- ctx.logger?.warn?.("@fonlan/dsh-quick-commands: subprocess service unavailable; commands cannot run");
880
- return;
881
- }
882
- const resolveRemote = () => {
883
- const sshPool = ctx.get("sshPool");
884
- if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
885
- return {
886
- pool: sshPool,
887
- resolveHost: (hostId) => readHostConfig((ns) => {
888
- try {
889
- return ctx.get("settings")?.get(ns);
890
- } catch {
891
- return;
892
- }
893
- }, hostId)
1029
+ const Config = QuickCommandsSettingsSchema;
1030
+ /** Configuration rides the entry config; edits persist through the settings service. */
1031
+ function apply(ctx, config) {
1032
+ const settings = registerSettings(ctx, config);
1033
+ ctx.inject(["subprocess", "webServer"], (sctx) => {
1034
+ const subprocess = sctx.get("subprocess");
1035
+ const resolveRemote = () => {
1036
+ const sshPool = sctx.get("sshPool");
1037
+ if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
1038
+ return {
1039
+ pool: sshPool,
1040
+ resolveHost: (hostId) => readHostConfig((ns) => {
1041
+ try {
1042
+ return sctx.get("settings")?.get(ns);
1043
+ } catch {
1044
+ return;
1045
+ }
1046
+ }, hostId)
1047
+ };
894
1048
  };
895
- };
896
- const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
897
- ctx.effect(() => registerApiRoutes(ctx, runner, settings), "quick-commands: api routes");
898
- ctx.effect(() => () => {
899
- for (const id of runner.listAll()) runner.kill(id);
900
- }, "quick-commands: process cleanup");
1049
+ const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
1050
+ sctx.effect(() => registerApiRoutes(sctx, runner, settings), "quick-commands: api routes");
1051
+ sctx.effect(() => () => {
1052
+ for (const id of runner.listAll()) runner.kill(id);
1053
+ }, "quick-commands: process cleanup");
1054
+ });
901
1055
  }
902
1056
  //#endregion
903
- export { apply, inject, name };
1057
+ export { Config, apply, inject, name };
@@ -13,7 +13,9 @@
13
13
  * absence of dsh-ssh degrades cleanly to local-only behaviour.
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
+ import type { QuickCommandsSettings } from './shared/contract.js';
16
17
  export declare const name = "@fonlan/dsh-quick-commands";
17
18
  export declare const inject: string[];
18
- /** No plugin-level config; all configuration lives in the settings namespace. */
19
- export declare function apply(ctx: Context): void;
19
+ export declare const Config: import("@deepseek-ai/schemastery").default<QuickCommandsSettings>;
20
+ /** Configuration rides the entry config; edits persist through the settings service. */
21
+ export declare function apply(ctx: Context, config: QuickCommandsSettings): void;
@@ -1,8 +1,18 @@
1
1
  /**
2
2
  * User-facing plugin settings (per-workspace command sets + popup anchor),
3
- * persisted through the settings service namespace `quick-commands`.
4
- * The DSH settings service requires a lowercase kebab-case namespace
5
- * (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
3
+ * carried by this bundle's entry config: dsh >= 0.1.7 owns plugin settings as
4
+ * entry config (the plugin's `Config` schema) and persists edits through
5
+ * `settings.update`.
6
+ *
7
+ * All three fields are declared `volatile()` — that is what makes them
8
+ * writable by the settings plane — and schemastery hands a volatile field to
9
+ * `apply()` as a LIVE HANDLE (`{ get() }`), not as the value. Reading the raw
10
+ * field therefore yields the handle object, which silently destroys the whole
11
+ * document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
12
+ * `.default('corner')` union arrives as the handle rather than a literal.
13
+ * Every read goes through {@link readField} instead, which unwraps handles and
14
+ * still accepts plain values (hosts that resolve the config without the
15
+ * volatile wrapper).
6
16
  */
7
17
  import type { Context } from '@deepseek-ai/cordis';
8
18
  import z from '@deepseek-ai/schemastery';
@@ -21,4 +31,4 @@ export interface QuickCommandsSettingsFace {
21
31
  /** Persist the output popup size (drag-resize). */
22
32
  setPopupSize(size: QuickPopupSize): Promise<void>;
23
33
  }
24
- export declare function registerSettings(ctx: Context): QuickCommandsSettingsFace;
34
+ export declare function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace;
@@ -0,0 +1,69 @@
1
+ /** Delimiters wrapping the shell's own PATH in the probe's stdout. */
2
+ export declare const PATH_START = "__DSH_QC_PATH_START__";
3
+ export declare const PATH_END = "__DSH_QC_PATH_END__";
4
+ /** Default deadline for the login-shell probe (ms). */
5
+ export declare const PROBE_TIMEOUT_MS = 3000;
6
+ /** Minimal probe runner shape: `child_process.execFile`, or a test double. */
7
+ export type ProbeRunner = (file: string, args: string[], options: {
8
+ timeout: number;
9
+ windowsHide: boolean;
10
+ env: NodeJS.ProcessEnv;
11
+ }) => Promise<{
12
+ stdout?: string | Buffer;
13
+ }>;
14
+ /**
15
+ * Extract the PATH a shell printed between the markers.
16
+ *
17
+ * Login profiles are free to print their own noise (nvm notices, version
18
+ * managers, MOTDs), so only the marked span counts — never the whole stdout.
19
+ * @param stdout - raw probe stdout.
20
+ * @returns the marked PATH, or '' when the markers are missing.
21
+ */
22
+ export declare function extractShellPath(stdout: unknown): string;
23
+ /**
24
+ * Put the login-shell PATH in front of the inherited one.
25
+ *
26
+ * The login PATH wins (that is where the user's toolchain lives); entries from
27
+ * the inherited PATH stay behind it as a fallback. Empty entries are dropped
28
+ * and duplicates keep their first position.
29
+ * @param loginPath - PATH reported by the login shell ('' when unavailable).
30
+ * @param inheritedPath - the host process's own PATH.
31
+ * @returns the merged PATH (the inherited value when there is no login one).
32
+ */
33
+ export declare function mergePaths(loginPath: string, inheritedPath: string | undefined): string;
34
+ /** The shell program whose login PATH describes this user's environment. */
35
+ export declare function loginShell(env?: NodeJS.ProcessEnv): string | undefined;
36
+ /**
37
+ * Ask `shell` (interactive login) for its PATH.
38
+ *
39
+ * Both flags matter: `-l` sources the profile files that own the system PATH
40
+ * (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
41
+ * interactive config where nvm/fnm/volta and other version managers install
42
+ * their shims (this user's node lives in `~/.zshrc`). A hung or noisy
43
+ * interactive profile cannot block a run: the probe carries a deadline, and
44
+ * only the marked span of stdout is read.
45
+ * @param options.shell - shell program; defaults to `$SHELL`.
46
+ * @param options.timeoutMs - probe deadline.
47
+ * @param options.run - probe runner, injectable for tests.
48
+ * @param options.env - environment the probe itself runs with.
49
+ * @returns the login PATH, or '' when it cannot be determined.
50
+ */
51
+ export declare function resolveLoginShellPath(options?: {
52
+ shell?: string | undefined;
53
+ timeoutMs?: number;
54
+ run?: ProbeRunner;
55
+ env?: NodeJS.ProcessEnv;
56
+ }): Promise<string>;
57
+ /** Cached, process-wide login-shell PATH (one probe per host lifetime). */
58
+ export declare function loginShellPath(): Promise<string>;
59
+ /** Drop the cache (tests, and settings reloads that may have changed `$SHELL`). */
60
+ export declare function resetLoginShellPathCache(): void;
61
+ /**
62
+ * Environment for one local quick command.
63
+ *
64
+ * A copy of the host environment with the login-shell PATH merged in front, so
65
+ * Desktop-launched hosts see the same toolchain a terminal-launched host does.
66
+ * @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
67
+ * @returns the spawn environment.
68
+ */
69
+ export declare function localCommandEnv(overrides?: Record<string, string>): Promise<NodeJS.ProcessEnv>;
@@ -33,6 +33,13 @@ export interface QuickCommandsSettings {
33
33
  /** Output popup size; absent until the user drags the resize handle (then per-anchor defaults apply). */
34
34
  popupSize?: QuickPopupSize;
35
35
  }
36
+ /**
37
+ * The document's anchor as a usable literal. Wire data is defensive by
38
+ * contract: an older host can still answer with schemastery's empty-object
39
+ * resolution (`{}`) for this field, which must never reach a CSS class name or
40
+ * a radio `checked` comparison.
41
+ */
42
+ export declare function anchorOf(value: unknown): 'corner' | 'button';
36
43
  /** Placeholder substitution values resolved for one execution. */
37
44
  export interface QuickRunPlaceholders {
38
45
  /** Workspace root path (also the execution cwd). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-quick-commands",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "DSH web plugin: per-workspace quick commands. Configure named shell commands per DSH workspace, run one from the session header with live stdout/stderr in a floating popup.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,18 @@
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  },
47
+ "scripts": {
48
+ "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
49
+ "build:client": "tsdown",
50
+ "prepare": "npm run build",
51
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
52
+ "test": "node --experimental-transform-types --test test/remote.test.ts test/settings-config.test.ts test/host-apply.test.ts test/shell-path.test.ts && node test/header-icons.test.mjs",
53
+ "bundle": "tsdown",
54
+ "watch": "tsdown --watch",
55
+ "release:patch": "npm version patch && git push && git push --tags",
56
+ "release:minor": "npm version minor && git push && git push --tags",
57
+ "release:major": "npm version major && git push && git push --tags"
58
+ },
47
59
  "dsh": {
48
60
  "bundle": {
49
61
  "patch": "./cordis.patch.yml"
@@ -63,42 +75,33 @@
63
75
  },
64
76
  "peerDependencies": {
65
77
  "@deepseek-ai/cordis": "^4.0.1",
78
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
79
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
80
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
81
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
66
82
  "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
67
83
  "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
68
84
  "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
69
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
70
85
  "@deepseek-ai/schemastery": "^3.18.2",
71
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
72
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
73
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
74
86
  "react": "^18.2.0",
75
87
  "react-dom": "^18.2.0"
76
88
  },
77
89
  "devDependencies": {
90
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
91
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
92
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
93
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-alpha.2",
94
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
95
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
96
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
97
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
98
+ "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
99
+ "@deepseek-ai/schemastery": "^3.18.3",
78
100
  "@types/node": "^22.0.0",
79
101
  "@types/react": "~18.3.1",
80
102
  "@types/react-dom": "~18.3.0",
81
103
  "tsdown": "^0.22.14",
82
- "typescript": "^5.9.0",
83
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
84
- "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
85
- "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
86
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
87
- "@deepseek-ai/schemastery": "^3.18.2",
88
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
89
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
90
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
91
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-alpha.2",
92
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2"
104
+ "typescript": "^5.9.0"
93
105
  },
94
- "scripts": {
95
- "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
96
- "build:client": "tsdown",
97
- "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
98
- "bundle": "tsdown",
99
- "watch": "tsdown --watch",
100
- "release:patch": "npm version patch && git push && git push --tags",
101
- "release:minor": "npm version minor && git push && git push --tags",
102
- "release:major": "npm version major && git push && git push --tags"
103
- }
104
- }
106
+ "packageManager": "pnpm@11.21.0"
107
+ }
@@ -8,20 +8,18 @@
8
8
  */
9
9
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
10
10
  import { createPortal } from 'react-dom'
11
- import {
12
- IconPlayOutline16,
13
- IconCloseOutline16,
14
- IconStopFill16,
15
- } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { PlayIcon, CloseIcon, StopIcon } from './icons'
16
12
  import { quickApi } from './api'
17
13
  import { useRunPopover, openMenu, closeMenu, openRun, closeRun } from './run-state'
18
- import type { QuickPopupSize } from '../shared/contract'
14
+ import { anchorOf, type QuickPopupSize } from '../shared/contract'
19
15
 
20
16
  /** Workspace row shape the standard useWorkspaces hook exposes. */
21
17
  interface WorkspaceRow {
22
18
  workspaceId: string
23
19
  path: string
24
20
  title: string
21
+ /** Session membership; the canonical link DSH itself resolves Workspaces by. */
22
+ sessionIds?: string[]
25
23
  }
26
24
 
27
25
  /** Session list state subset we read (cwd + id). */
@@ -91,7 +89,12 @@ function useCurrentWorkspace(
91
89
  ): { workspace: WorkspaceRow | undefined; cwd: string | undefined } {
92
90
  const row = useSessions?.((s) => s.byId[sessionId]) as SessionRow | undefined
93
91
  const workspaces = useWorkspaces?.((s) => s.items) as WorkspaceRow[] | undefined
94
- return { workspace: matchWorkspace(row?.cwd, workspaces ?? []), cwd: row?.cwd }
92
+ const items = workspaces ?? []
93
+ // Membership first (how DSH core resolves a session's Workspace, and the only
94
+ // link that survives when a session runs outside the Workspace root), then
95
+ // the longest cwd prefix as the fallback for hosts without membership data.
96
+ const byMembership = items.find((ws) => ws.sessionIds?.includes(sessionId) === true)
97
+ return { workspace: byMembership ?? matchWorkspace(row?.cwd, items), cwd: row?.cwd }
95
98
  }
96
99
 
97
100
  /** Header icon button + command menu + live output popup. */
@@ -159,7 +162,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
159
162
  const own = entries.find((e) => e.workspaceId === workspace?.workspaceId)
160
163
  setCommands(own === undefined ? [] : own.commands)
161
164
  setMenuRemoteHost(own?.remoteHost ?? null)
162
- setAnchor(settings.popupAnchor)
165
+ setAnchor(anchorOf(settings.popupAnchor))
163
166
  setPopupSize(settings.popupSize)
164
167
  setMenuLoadError(null)
165
168
  } catch (e) {
@@ -201,7 +204,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
201
204
  disabled={workspace === undefined}
202
205
  onClick={() => { popover.menuOpen ? closeMenu() : openMenu() }}
203
206
  >
204
- <IconPlayOutline16 size={15} />
207
+ <PlayIcon size={15} />
205
208
  </button>
206
209
 
207
210
  {popover.menuOpen && workspace !== undefined && menuPosition !== null && createPortal(
@@ -216,7 +219,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
216
219
  {menuRemoteHost !== null && <span className="qc-menu-remote" title={`SSH · ${menuRemoteHost}`}>SSH</span>}
217
220
  <span className="qc-menu-ws">{workspace.title}</span>
218
221
  <button type="button" className="qc-menu-close" aria-label={t('runClose')} onClick={closeMenu}>
219
- <IconCloseOutline16 size={12} />
222
+ <CloseIcon size={12} />
220
223
  </button>
221
224
  </div>
222
225
  {commands.length === 0 && (
@@ -495,7 +498,7 @@ function RunPopup(props: {
495
498
  aria-label={t('runKill')}
496
499
  onClick={() => void kill()}
497
500
  >
498
- <IconStopFill16 size={14} />
501
+ <StopIcon size={14} />
499
502
  </button>
500
503
  <button
501
504
  type="button"
@@ -505,7 +508,7 @@ function RunPopup(props: {
505
508
  onPointerUp={onClosePointerUp}
506
509
  onClick={closeOutput}
507
510
  >
508
- <IconCloseOutline16 size={14} />
511
+ <CloseIcon size={14} />
509
512
  </button>
510
513
  </header>
511
514