@dungsil/dsh-plugin-ui-worktree 0.5.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/dist/client.js CHANGED
@@ -20,7 +20,7 @@ function installStyles(css) {
20
20
  var git_branch_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <circle cx=\"6\" cy=\"5\" r=\"3\"/>\n <circle cx=\"6\" cy=\"19\" r=\"3\"/>\n <circle cx=\"18\" cy=\"6\" r=\"3\"/>\n <path d=\"M6 8v8M6 14h7a5 5 0 0 0 5-5\"/>\n</svg>\n";
21
21
  // ../../tools/ui/src/composer.tsx
22
22
  const composerStyles = `
23
- :where(.dsh-plugin-composer-trigger) { display: flex; align-items: center; gap: 4px; min-width: 0; height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
23
+ :where(.dsh-plugin-composer-trigger) { display: flex; align-items: center; gap: 4px; min-width: 0; height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; line-height: 1.5385; cursor: pointer; }
24
24
  :where(.dsh-plugin-composer-trigger):disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
25
25
  .dsh-plugin-composer-icon, .dsh-plugin-composer-chevron { display: inline-flex; flex: none; }
26
26
  .dsh-plugin-composer-label, .dsh-plugin-composer-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -56,12 +56,13 @@ const ComposerTrigger = /* @__PURE__ */ (0, react.forwardRef)(function ComposerT
56
56
  });
57
57
  });
58
58
  // ../../tools/ui/src/settings.tsx
59
- /** The rc.5 settings shell has no icon slot, so decorate only the matching navigation rows. */
59
+ /** Decorate matching navigation rows in both the rc.5 and 0.2.2 settings shells. */
60
60
  function installSettingsIcon(id, labels, icon) {
61
61
  const attribute = "data-dsh-settings-icon";
62
62
  const selector = `[${attribute}="${id}"]`;
63
63
  const removeStyles = installStyles(`
64
- ${selector} > svg { display: none; }
64
+ ${selector} > svg,
65
+ ${selector} > [aria-hidden="true"]:not([data-dsh-settings-icon-glyph]) { display: none; }
65
66
  ${typeof icon === "string" ? `${selector}::before { content: ''; display: block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("data:image/svg+xml,${encodeURIComponent(icon)}") center / contain no-repeat; }` : ""}
66
67
  ${selector} > [data-dsh-settings-icon-glyph] { display: inline-flex; flex: none; }
67
68
  `);
@@ -188,10 +189,15 @@ function currentWorktree(view) {
188
189
  return cwd === path || cwd.startsWith(path + "/");
189
190
  }).sort((a, b) => comparablePath(b.path).length - comparablePath(a.path).length)[0];
190
191
  }
191
- function isWorktreeView(value) {
192
+ function isWorktreeSelectionView(value) {
192
193
  if (!value || typeof value !== "object") return false;
193
194
  const view = value;
194
- return typeof view.cwd === "string" && (view.needsInitialization === void 0 || typeof view.needsInitialization === "boolean") && (view.pendingCreation === void 0 || view.pendingCreation !== null && typeof view.pendingCreation.name === "string" && typeof view.pendingCreation.failed === "boolean") && Array.isArray(view.worktrees) && view.worktrees.every((item) => item && typeof item.path === "string" && (item.branch === null || typeof item.branch === "string"));
195
+ return typeof view.cwd === "string" && (view.locked === void 0 || typeof view.locked === "boolean") && (view.pendingCreation === void 0 || view.pendingCreation !== null && typeof view.pendingCreation.name === "string" && typeof view.pendingCreation.failed === "boolean");
196
+ }
197
+ function isWorktreeView(value) {
198
+ if (!isWorktreeSelectionView(value)) return false;
199
+ const view = value;
200
+ return (view.needsInitialization === void 0 || typeof view.needsInitialization === "boolean") && Array.isArray(view.worktrees) && view.worktrees.every((item) => item && typeof item.path === "string" && (item.branch === null || typeof item.branch === "string"));
195
201
  }
196
202
  // src/errors.ts
197
203
  const failureCodes = [
@@ -226,18 +232,22 @@ function failureCode(error) {
226
232
  // src/requests.ts
227
233
  const copy$1 = {
228
234
  zh: {
229
- title: "Git 工作树",
235
+ title: "下一任务的位置",
236
+ choose: "选择工作树",
237
+ savingSelection: "正在保存下一任务的位置…",
238
+ selectionError: "请确认工作树选择后再发送提示。",
239
+ locked: "任务开始后无法更改此会话的工作树。请在其他工作树中新建会话。",
230
240
  detached: "分离的 HEAD",
231
241
  unknown: "无法获取分支",
232
242
  create: "新工作树",
233
243
  primaryBranch: "当前分支",
234
- reservationHint: "发送下一条提示后,将先创建并切换到新工作树再开始任务。选择现有工作树可取消。",
235
- creationError: "无法确认创建结果,尚未开始任务。请刷新列表,选择工作树或重新预约,然后重新发送提示。",
244
+ reservationHint: "发送下一条提示后,将先创建并切换到新工作树再开始任务。选择当前分支可取消。",
245
+ creationError: "无法确认创建结果,尚未开始任务。请选择当前分支或重新预约,然后重新发送提示。",
236
246
  close: "关闭",
237
247
  refresh: "刷新工作树",
238
248
  loading: "正在加载工作树…",
239
249
  busy: "正在切换工作树…",
240
- changed: "已切换工作树。",
250
+ changed: "已选择下一任务的位置。",
241
251
  empty: "未找到 Git 工作树。",
242
252
  wait: "请等待会话准备就绪且当前任务结束。",
243
253
  error: "无法确认当前状态。请刷新工作树以检查状态后再重试。",
@@ -251,11 +261,11 @@ const copy$1 = {
251
261
  describe: "查询工作树",
252
262
  initialize: "初始化工作树选择",
253
263
  create: "创建工作树",
254
- switch: "切换工作树"
264
+ select: "保存下一任务的位置"
255
265
  },
256
266
  reasons: {
257
267
  busy: "会话中有正在执行的任务。请在任务结束后重新查询。",
258
- conflict: "当前工作目录已更改。请刷新列表后再进行更改。",
268
+ conflict: "当前工作目录已更改或工作树选择已锁定。请重新查询状态。",
259
269
  invalid: "请求无效。请刷新列表并检查分支名称或工作树。",
260
270
  operation_failed: "主机无法完成操作。请检查 Git 和工作树配置。",
261
271
  session_not_found: "会话不可用。请重新打开会话后重试。",
@@ -263,24 +273,28 @@ const copy$1 = {
263
273
  auth_required: "连接需要身份验证。请重新打开应用后重试。",
264
274
  endpoint_missing: "主机没有工作树接口。请确认插件已启用并重启应用。",
265
275
  client_unavailable: "连接缺少工作树 RPC 客户端。请刷新页面或更新应用。",
266
- invalid_response: "主机返回的工作树列表无效。请检查应用和插件版本。",
276
+ invalid_response: "主机返回的工作树状态无效。请检查应用和插件版本。",
267
277
  connection_error: "收到有效响应前连接失败。请检查连接后重新查询。",
268
278
  timeout: "请求超时。请在当前任务结束后重新查询。"
269
279
  }
270
280
  },
271
281
  en: {
272
- title: "Git worktree",
282
+ title: "Next task location",
283
+ choose: "Choose worktree",
284
+ savingSelection: "Saving next task location…",
285
+ selectionError: "Confirm the worktree selection before sending your prompt.",
286
+ locked: "The worktree cannot change after work starts. Create a new session in another worktree.",
273
287
  detached: "Detached HEAD",
274
288
  unknown: "Branch unavailable",
275
289
  create: "New worktree",
276
290
  primaryBranch: "Current branch",
277
- reservationHint: "The next prompt will create and enter a new worktree before starting work. Select an existing worktree to cancel.",
278
- creationError: "Creation could not be confirmed, so work did not start. Refresh the list, select a worktree or schedule a new one, then resend your prompt.",
291
+ reservationHint: "The next prompt will create and enter a new worktree before starting work. Select the current branch to cancel.",
292
+ creationError: "Creation could not be confirmed, so work did not start. Select the current branch or schedule a new worktree, then resend your prompt.",
279
293
  close: "Close",
280
294
  refresh: "Refresh worktrees",
281
295
  loading: "Loading worktrees…",
282
296
  busy: "Changing worktree…",
283
- changed: "Worktree changed.",
297
+ changed: "Next task location selected.",
284
298
  empty: "No Git worktrees found.",
285
299
  wait: "Wait until the session is ready and idle.",
286
300
  error: "Could not confirm the current state. Refresh worktrees to check before trying again.",
@@ -294,11 +308,11 @@ const copy$1 = {
294
308
  describe: "List worktrees",
295
309
  initialize: "Initialize worktree selection",
296
310
  create: "Create worktree",
297
- switch: "Switch worktree"
311
+ select: "Save next task location"
298
312
  },
299
313
  reasons: {
300
314
  busy: "The session has an active task. Retry after the task stops.",
301
- conflict: "The current directory changed. Reload the list before another change.",
315
+ conflict: "The current directory changed or the worktree selection is locked. Reload to check the state.",
302
316
  invalid: "The request is invalid. Reload the list and check the branch name or worktree.",
303
317
  operation_failed: "The host could not complete the operation. Check Git and the worktree configuration.",
304
318
  session_not_found: "The session is unavailable. Reopen the session and retry.",
@@ -306,24 +320,28 @@ const copy$1 = {
306
320
  auth_required: "The connection requires authentication. Reopen the application and retry.",
307
321
  endpoint_missing: "The host has no worktree endpoint. Check that the plugin is active and restart the application.",
308
322
  client_unavailable: "The connection has no worktree RPC client. Reload the page or update the application.",
309
- invalid_response: "The host returned an invalid worktree list. Check the application and plugin versions.",
323
+ invalid_response: "The host returned an invalid worktree state. Check the application and plugin versions.",
310
324
  connection_error: "The connection failed before a valid response. Check the connection and retry.",
311
325
  timeout: "The request timed out. Retry after the current task stops."
312
326
  }
313
327
  },
314
328
  ko: {
315
- title: "Git worktree",
329
+ title: "다음 작업 위치",
330
+ choose: "워크트리 선택",
331
+ savingSelection: "다음 작업 위치를 저장하는 중…",
332
+ selectionError: "워크트리 선택을 확인한 뒤 프롬프트를 전송하세요.",
333
+ locked: "작업을 시작한 세션에서는 워크트리를 바꿀 수 없습니다. 다른 워크트리에서 새 세션을 만드세요.",
316
334
  detached: "분리된 HEAD",
317
335
  unknown: "브랜치 확인 불가",
318
336
  create: "새 워크트리",
319
337
  primaryBranch: "현재 브랜치",
320
- reservationHint: "다음 프롬프트를 전송하면 새 워크트리를 생성·전환한 뒤 작업을 시작합니다. 기존 워크트리를 선택하면 예약을 취소합니다.",
321
- creationError: "생성 결과를 확인하지 못해 작업을 시작하지 않았습니다. 목록을 새로고침하고 워크트리를 선택하거나 다시 예약한 뒤 프롬프트를 다시 전송하세요.",
338
+ reservationHint: "다음 프롬프트를 전송하면 새 워크트리를 생성·전환한 뒤 작업을 시작합니다. 현재 브랜치를 선택하면 예약을 취소합니다.",
339
+ creationError: "생성 결과를 확인하지 못해 작업을 시작하지 않았습니다. 현재 브랜치를 선택하거나 다시 예약한 뒤 프롬프트를 다시 전송하세요.",
322
340
  close: "닫기",
323
341
  refresh: "워크트리 새로고침",
324
342
  loading: "워크트리를 불러오는 중…",
325
343
  busy: "워크트리 변경 중…",
326
- changed: "워크트리를 변경했습니다.",
344
+ changed: "다음 작업 위치를 선택했습니다.",
327
345
  empty: "Git worktree가 없습니다.",
328
346
  wait: "세션이 준비되고 실행 중인 작업이 끝나면 사용할 수 있습니다.",
329
347
  error: "현재 상태를 확인하지 못했습니다. 다시 시도하기 전에 새로고침으로 상태를 확인하세요.",
@@ -337,11 +355,11 @@ const copy$1 = {
337
355
  describe: "워크트리 조회",
338
356
  initialize: "워크트리 선택 초기화",
339
357
  create: "워크트리 생성",
340
- switch: "워크트리 전환"
358
+ select: "다음 작업 위치 저장"
341
359
  },
342
360
  reasons: {
343
361
  busy: "세션에서 작업을 실행 중입니다. 작업이 끝난 뒤 다시 조회하세요.",
344
- conflict: "현재 작업 경로가 바뀌었습니다. 목록을 새로고침한 뒤 변경하세요.",
362
+ conflict: "현재 작업 경로가 바뀌었거나 워크트리 선택이 고정됐습니다. 다시 조회해 상태를 확인하세요.",
345
363
  invalid: "요청이 올바르지 않습니다. 목록을 새로고침하고 브랜치 이름이나 워크트리를 확인하세요.",
346
364
  operation_failed: "호스트가 작업을 완료하지 못했습니다. Git과 워크트리 설정을 확인하세요.",
347
365
  session_not_found: "세션을 찾지 못했습니다. 세션을 다시 열고 조회하세요.",
@@ -349,7 +367,7 @@ const copy$1 = {
349
367
  auth_required: "연결 인증이 필요합니다. 앱을 다시 열고 조회하세요.",
350
368
  endpoint_missing: "호스트에 워크트리 API가 없습니다. 플러그인을 활성화했는지 확인하고 앱을 다시 시작하세요.",
351
369
  client_unavailable: "연결에 워크트리 RPC 기능이 없습니다. 페이지를 새로고침하거나 앱을 업데이트하세요.",
352
- invalid_response: "호스트가 올바르지 않은 워크트리 목록을 반환했습니다. 앱과 플러그인 버전을 확인하세요.",
370
+ invalid_response: "호스트가 올바르지 않은 워크트리 상태를 반환했습니다. 앱과 플러그인 버전을 확인하세요.",
353
371
  connection_error: "정상 응답을 받기 전에 연결이 실패했습니다. 연결을 확인하고 다시 조회하세요.",
354
372
  timeout: "요청 시간이 초과됐습니다. 실행 중인 작업이 끝난 뒤 다시 조회하세요."
355
373
  }
@@ -706,6 +724,7 @@ const styles = `${settingsStyles}${composerStyles}
706
724
  .dsh-worktree-badge { flex: none; padding: 1px 5px; border: 1px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; font-weight: 400; }
707
725
  .dsh-worktree-trigger { display: inline-flex; max-width: 220px; font-weight: 500; }
708
726
  .dsh-worktree-trigger .dsh-plugin-composer-icon svg { width: 14px; height: 14px; }
727
+ .dsh-worktree-trigger:not([aria-haspopup]) .dsh-plugin-composer-chevron { display: none; }
709
728
  .dsh-worktree-trigger:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 0; }
710
729
  @container (width <= 460px) {
711
730
  .dsh-worktree-trigger .dsh-plugin-composer-label { display: none; }
@@ -944,7 +963,8 @@ const inject = [
944
963
  "slots",
945
964
  "locale",
946
965
  "connection",
947
- "uiConversation"
966
+ "uiConversation",
967
+ "conversation"
948
968
  ];
949
969
  function apply(ctx) {
950
970
  ctx.effect(() => installSettingsIcon(name, [
@@ -954,25 +974,45 @@ function apply(ctx) {
954
974
  ].map(settingsLabel), git_branch_default));
955
975
  const connection = ctx.get("connection");
956
976
  installProgress(ctx);
957
- function Picker({ sessionId, useSession }) {
977
+ const synced = /* @__PURE__ */ new Map();
978
+ const ownedBlocks = /* @__PURE__ */ new Map();
979
+ const blocks = ctx.conversation.blocks;
980
+ ctx.effect(() => () => {
981
+ const owned = [...ownedBlocks];
982
+ ownedBlocks.clear();
983
+ for (const [id, block] of owned) if (blocks.storeFor(id).getSnapshot() === block) blocks.set(id, void 0);
984
+ });
985
+ function Picker({ sessionId, useSession, useProjection, useInput }) {
958
986
  const { active: language } = useLocaleSnapshot(ctx.locale);
959
987
  const text = copy$1[language.startsWith("ko") ? "ko" : language.startsWith("zh") ? "zh" : "en"];
960
- const unavailable = useSession((s) => s.running || s.removed || s.openState !== "open" || s.pendingSubmissions.length > 0);
988
+ const inputBusy = useInput((input) => input.phase !== "plain");
989
+ const unavailable = useSession((s) => s.running || s.removed || s.openState !== "open" || s.pendingSubmissions.length > 0) || inputBusy;
961
990
  const unavailableRef = (0, react.useRef)(unavailable);
962
991
  unavailableRef.current = unavailable;
963
992
  const unreadable = useSession((s) => s.removed || s.openState !== "open");
964
993
  const unreadableRef = (0, react.useRef)(unreadable);
965
994
  unreadableRef.current = unreadable;
995
+ const cwd = useProjection("workingDirectory");
996
+ const started = useProjection("sessionListMetadata")?.blank === false;
997
+ const cached = synced.get(sessionId);
998
+ const initialView = cached?.cwd === cwd ? cached : void 0;
966
999
  const previousReadiness = (0, react.useRef)({
967
1000
  unreadable: true,
968
- unavailable
1001
+ unavailable,
1002
+ cwd
969
1003
  });
970
- const [view, setView] = (0, react.useState)();
971
- const [loading, setLoading] = (0, react.useState)(true);
972
- const [busy, setBusy] = (0, react.useState)(false);
1004
+ const [view, setView] = (0, react.useState)(initialView);
1005
+ const [loading, setLoading] = (0, react.useState)(!initialView);
1006
+ const viewRef = (0, react.useRef)(view);
1007
+ viewRef.current = view;
1008
+ const selected = (0, react.useRef)();
1009
+ const saving = (0, react.useRef)(false);
1010
+ const version = (0, react.useRef)(0);
973
1011
  const [error, setError] = (0, react.useState)();
974
1012
  const [changed, setChanged] = (0, react.useState)(false);
975
1013
  const [open, setOpen] = (0, react.useState)(false);
1014
+ const needsSync = (0, react.useRef)(true);
1015
+ needsSync.current = !view || !view.locked && (!!view.needsInitialization || !!view.pendingCreation);
976
1016
  const pending = (0, react.useRef)(false);
977
1017
  const reloadRequested = (0, react.useRef)(false);
978
1018
  const needsRefresh = (0, react.useRef)(false);
@@ -982,38 +1022,74 @@ function apply(ctx) {
982
1022
  const errorTitle = (0, react.useId)();
983
1023
  const errorDescription = (0, react.useId)();
984
1024
  const status = (0, react.useId)();
985
- const disabled = unavailable || loading || busy || !!error || !view;
986
1025
  const creation = view?.pendingCreation;
1026
+ const locked = !!view?.locked || started && !creation;
1027
+ const disabled = locked || unavailable || loading || !view;
987
1028
  const detailError = error ?? (creation?.failed ? {
988
1029
  operation: "create",
989
1030
  code: "operation_failed",
990
1031
  uncertain: true
991
1032
  } : void 0);
992
1033
  const errorLabel = detailError?.uncertain ? text.mutationError : text.lookupError;
1034
+ const gate = (reason) => {
1035
+ const store = blocks.storeFor(sessionId);
1036
+ const owned = ownedBlocks.get(sessionId);
1037
+ const current = store.getSnapshot();
1038
+ if (!reason) {
1039
+ ownedBlocks.delete(sessionId);
1040
+ if (owned && current === owned) blocks.set(sessionId, void 0);
1041
+ } else {
1042
+ synced.delete(sessionId);
1043
+ const block = owned?.reason === reason ? owned : { reason };
1044
+ ownedBlocks.set(sessionId, block);
1045
+ if (!current || current === owned) blocks.set(sessionId, block);
1046
+ }
1047
+ };
993
1048
  const recordFailure = (operation, cause) => {
994
1049
  const code = failureCode(cause);
995
1050
  needsRefresh.current = true;
1051
+ const uncertain = operation !== "describe" || ownedBlocks.has(sessionId);
1052
+ if (uncertain) gate(text.selectionError);
996
1053
  setError((previous) => ({
997
1054
  operation,
998
1055
  code,
999
- uncertain: operation !== "describe" || !!previous?.uncertain
1056
+ uncertain: uncertain || !!previous?.uncertain
1000
1057
  }));
1001
1058
  console.warn("[dungsil-ui-worktree]", {
1002
1059
  operation,
1003
1060
  code
1004
1061
  });
1005
1062
  };
1006
- const request = async (operation, payload, signal) => {
1063
+ const call = async (operation, payload, signal) => {
1007
1064
  if (typeof connection?.rpc?.call !== "function") throw { code: "client_unavailable" };
1008
1065
  const result = await connection.rpc.call(channel, endpointPrefix + operation, payload, signal);
1009
1066
  signal.throwIfAborted();
1010
1067
  if (!result.ok) throw result.error;
1011
- if (!isWorktreeView(result.value)) throw { code: "invalid_response" };
1012
1068
  return result.value;
1013
1069
  };
1070
+ const request = async (operation, payload, signal) => {
1071
+ const value = await call(operation, payload, signal);
1072
+ if (!isWorktreeView(value)) throw { code: "invalid_response" };
1073
+ return value;
1074
+ };
1075
+ const updateView = (next) => {
1076
+ viewRef.current = next;
1077
+ setView(next);
1078
+ };
1079
+ const mergeSelection = (next) => {
1080
+ const previous = viewRef.current;
1081
+ if (!previous) return;
1082
+ updateView({
1083
+ ...previous,
1084
+ cwd: next.cwd,
1085
+ pendingCreation: next.pendingCreation,
1086
+ locked: next.locked,
1087
+ needsInitialization: false
1088
+ });
1089
+ };
1014
1090
  const load = async (explicit = false) => {
1015
1091
  const controller = lifetime.current;
1016
- if (!controller || controller.signal.aborted || unreadableRef.current || needsRefresh.current && !explicit) return;
1092
+ if (!controller || controller.signal.aborted || unreadableRef.current || saving.current || needsRefresh.current && !explicit) return;
1017
1093
  if (pending.current) {
1018
1094
  reloadRequested.current = true;
1019
1095
  return;
@@ -1025,20 +1101,25 @@ function apply(ctx) {
1025
1101
  try {
1026
1102
  const view = await request("describe", { sessionId }, controller.signal);
1027
1103
  if (controller.signal.aborted) return;
1028
- setView(view);
1029
- if (view.needsInitialization && !unavailableRef.current) {
1104
+ updateView(view);
1105
+ if (view.needsInitialization && !view.locked && !unavailableRef.current) {
1030
1106
  operation = "initialize";
1107
+ gate(text.savingSelection);
1031
1108
  const initialized = await request("initialize", {
1032
1109
  sessionId,
1033
1110
  cwd: view.cwd
1034
1111
  }, controller.signal);
1035
1112
  if (controller.signal.aborted) return;
1036
- setView(initialized);
1113
+ updateView(initialized);
1037
1114
  }
1038
1115
  needsRefresh.current = false;
1039
1116
  setError(void 0);
1117
+ gate();
1040
1118
  } catch (cause) {
1041
- if (!controller.signal.aborted && failureCode(cause) !== "busy") recordFailure(operation, cause);
1119
+ if (!controller.signal.aborted) {
1120
+ if (failureCode(cause) !== "busy") recordFailure(operation, cause);
1121
+ else if (!needsRefresh.current && operation === "initialize") gate();
1122
+ }
1042
1123
  } finally {
1043
1124
  if (!controller.signal.aborted) {
1044
1125
  pending.current = false;
@@ -1054,8 +1135,15 @@ function apply(ctx) {
1054
1135
  const controller = new AbortController();
1055
1136
  lifetime.current = controller;
1056
1137
  pending.current = false;
1138
+ const store = blocks.storeFor(sessionId);
1139
+ const restoreGate = () => {
1140
+ const block = ownedBlocks.get(sessionId);
1141
+ if (block && !store.getSnapshot()) blocks.set(sessionId, block);
1142
+ };
1143
+ restoreGate();
1144
+ const unsubscribe = store.subscribe(restoreGate);
1057
1145
  const focus = () => {
1058
- load();
1146
+ if (needsSync.current && !pending.current) load();
1059
1147
  };
1060
1148
  const resize = () => {
1061
1149
  setOpen(false);
@@ -1064,6 +1152,7 @@ function apply(ctx) {
1064
1152
  window.addEventListener("resize", resize);
1065
1153
  return () => {
1066
1154
  controller.abort();
1155
+ unsubscribe();
1067
1156
  window.removeEventListener("focus", focus);
1068
1157
  window.removeEventListener("resize", resize);
1069
1158
  };
@@ -1072,46 +1161,85 @@ function apply(ctx) {
1072
1161
  const previous = previousReadiness.current;
1073
1162
  previousReadiness.current = {
1074
1163
  unreadable,
1075
- unavailable
1164
+ unavailable,
1165
+ cwd
1076
1166
  };
1077
- if (!unreadable && (previous.unreadable || previous.unavailable && !unavailable)) load();
1078
- }, [unreadable, unavailable]);
1167
+ if (!unreadable && ((previous.unreadable || previous.unavailable && !unavailable) && needsSync.current || previous.cwd !== cwd)) load();
1168
+ }, [
1169
+ unreadable,
1170
+ unavailable,
1171
+ cwd
1172
+ ]);
1173
+ (0, react.useEffect)(() => {
1174
+ if (locked && view && !creation && view.cwd === cwd && !error && !ownedBlocks.has(sessionId)) synced.set(sessionId, {
1175
+ ...view,
1176
+ locked: true,
1177
+ needsInitialization: false
1178
+ });
1179
+ else synced.delete(sessionId);
1180
+ }, [
1181
+ sessionId,
1182
+ view,
1183
+ locked,
1184
+ creation,
1185
+ cwd,
1186
+ error
1187
+ ]);
1079
1188
  (0, react.useEffect)(() => {
1080
1189
  if (disabled) setOpen(false);
1081
1190
  }, [disabled]);
1082
- const change = async (endpoint, path) => {
1191
+ const change = async (choice) => {
1083
1192
  const controller = lifetime.current;
1084
- if (disabled || pending.current || needsRefresh.current || !view || !controller || controller.signal.aborted) return;
1085
- pending.current = true;
1086
- setBusy(true);
1087
- setChanged(false);
1193
+ const currentView = viewRef.current;
1194
+ if (locked || unavailableRef.current || loading || pending.current || !currentView || !controller || controller.signal.aborted) return;
1195
+ const intent = {
1196
+ version: ++version.current,
1197
+ cwd: currentView.cwd,
1198
+ choice
1199
+ };
1200
+ selected.current = intent;
1201
+ mergeSelection({
1202
+ cwd: currentView.cwd,
1203
+ pendingCreation: choice === "new" ? {
1204
+ name: text.create,
1205
+ failed: false
1206
+ } : void 0
1207
+ });
1208
+ setError(void 0);
1209
+ setChanged(true);
1210
+ needsRefresh.current = false;
1211
+ gate(text.savingSelection);
1212
+ if (saving.current) return;
1213
+ saving.current = true;
1088
1214
  try {
1089
- const payload = endpoint === "create" ? {
1090
- sessionId,
1091
- cwd: view.cwd
1092
- } : {
1093
- sessionId,
1094
- cwd: view.cwd,
1095
- path
1096
- };
1097
- const nextView = await request(endpoint, payload, controller.signal);
1098
- if (controller.signal.aborted) return;
1099
- setView(nextView);
1100
- setChanged(true);
1101
- pending.current = false;
1102
- await load();
1103
- } catch (cause) {
1104
- if (!controller.signal.aborted) recordFailure(endpoint, cause);
1105
- } finally {
1106
- if (!controller.signal.aborted) {
1107
- pending.current = false;
1108
- setBusy(false);
1215
+ while (selected.current && !controller.signal.aborted) {
1216
+ const next = selected.current;
1217
+ try {
1218
+ const value = await call("select", {
1219
+ sessionId,
1220
+ cwd: next.cwd,
1221
+ choice: next.choice
1222
+ }, controller.signal);
1223
+ if (!isWorktreeSelectionView(value)) throw { code: "invalid_response" };
1224
+ if (selected.current?.version !== next.version) continue;
1225
+ mergeSelection(value);
1226
+ selected.current = void 0;
1227
+ gate();
1228
+ } catch (cause) {
1229
+ if (controller.signal.aborted) return;
1230
+ if (selected.current?.version !== next.version) continue;
1231
+ selected.current = void 0;
1232
+ recordFailure("select", cause);
1233
+ return;
1234
+ }
1109
1235
  }
1236
+ } finally {
1237
+ saving.current = false;
1110
1238
  }
1111
1239
  };
1112
1240
  (0, react.useEffect)(() => {
1113
- if (!busy && changed) trigger.current?.focus();
1114
- }, [busy, changed]);
1241
+ if (changed) trigger.current?.focus();
1242
+ }, [changed]);
1115
1243
  (0, react.useEffect)(() => {
1116
1244
  if (!detailError && errorDialog.current?.open) errorDialog.current.close();
1117
1245
  }, [error, creation?.failed]);
@@ -1127,13 +1255,18 @@ function apply(ctx) {
1127
1255
  current,
1128
1256
  view
1129
1257
  ]);
1130
- const branch = creation ? creation.failed ? text.mutationError : text.create : current ? current.branch ?? text.detached : text.unknown;
1258
+ const selectCurrent = () => {
1259
+ setOpen(false);
1260
+ if (disabled) return;
1261
+ if (viewRef.current?.pendingCreation || needsRefresh.current) change("current");
1262
+ };
1263
+ const branch = creation ? text.create : current ? current.branch ?? text.detached : text.unknown;
1131
1264
  const label = detailError ? errorLabel : view ? branch : loading ? text.loading : text.title;
1132
- const notice = loading ? text.loading : busy ? text.busy : error ? errorLabel : unavailable ? text.wait : creation ? creation.failed ? text.creationError : text.reservationHint : changed ? text.changed : view?.worktrees.length === 0 ? text.empty : "";
1265
+ const notice = loading ? text.loading : error ? errorLabel : unavailable ? text.wait : locked ? text.locked : creation ? creation.failed ? text.creationError : text.reservationHint : changed ? text.changed : view?.worktrees.length === 0 ? text.empty : "";
1133
1266
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1134
1267
  className: "dsh-worktree",
1135
1268
  children: [
1136
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
1269
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
1137
1270
  className: "dsh-worktree-picker",
1138
1271
  open,
1139
1272
  side: "top",
@@ -1145,16 +1278,16 @@ function apply(ctx) {
1145
1278
  className: "dsh-worktree-trigger",
1146
1279
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconBranchOutlineRegular, {}),
1147
1280
  label,
1148
- disabled: detailError ? loading || busy : disabled,
1281
+ disabled: detailError ? loading : disabled,
1149
1282
  "aria-label": `${text.title}: ${label}`,
1150
- "aria-haspopup": detailError ? "dialog" : "menu",
1151
- "aria-expanded": detailError ? void 0 : open,
1283
+ "aria-haspopup": detailError ? "dialog" : locked ? void 0 : "menu",
1284
+ "aria-expanded": detailError || locked ? void 0 : open,
1152
1285
  "aria-describedby": status,
1153
- "aria-busy": loading || busy,
1154
- title: label,
1286
+ "aria-busy": loading,
1287
+ title: locked ? text.locked : `${text.title}: ${branch}${creation ? ` — ${text.reservationHint}` : ""}`,
1155
1288
  onClick: () => {
1156
1289
  if (detailError) errorDialog.current?.showModal();
1157
- else setOpen((value) => !value);
1290
+ else if (!disabled) setOpen((value) => !value);
1158
1291
  },
1159
1292
  onKeyDown: (event) => {
1160
1293
  if (!detailError && !disabled && !open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
@@ -1163,51 +1296,37 @@ function apply(ctx) {
1163
1296
  }
1164
1297
  }
1165
1298
  }),
1166
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1299
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1167
1300
  className: "dsh-worktree-pinned",
1168
1301
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuItemButton, {
1169
1302
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPlusOutlineRegular, {}),
1170
1303
  current: !!creation,
1171
1304
  onSelect: () => {
1172
1305
  setOpen(false);
1173
- if (!creation || creation.failed) change("create");
1306
+ const latest = viewRef.current?.pendingCreation;
1307
+ if (!disabled && (!latest || latest.failed || needsRefresh.current)) change("new");
1174
1308
  },
1175
1309
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1176
1310
  className: "dsh-worktree-option",
1177
1311
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: text.create }), creation && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})]
1178
1312
  })
1179
- }), view?.worktrees.slice(0, 1).map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuItemButton, {
1313
+ }), current && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuItemButton, {
1180
1314
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconBranchOutlineRegular, {}),
1181
- current: !creation && current === item,
1182
- onSelect: () => {
1183
- setOpen(false);
1184
- if (creation || current !== item) change("switch", item.path);
1185
- },
1315
+ current: !creation,
1316
+ onSelect: selectCurrent,
1186
1317
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1187
1318
  className: "dsh-worktree-option",
1188
1319
  children: [
1189
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.branch ?? text.detached }),
1320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: current.branch ?? text.detached }),
1190
1321
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1191
1322
  className: "dsh-worktree-badge",
1192
1323
  children: text.primaryBranch
1193
1324
  }),
1194
- !creation && current === item && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
1325
+ !creation && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
1195
1326
  ]
1196
1327
  })
1197
- }, item.path))]
1198
- }), view?.worktrees.slice(1).map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuItemButton, {
1199
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconBranchOutlineRegular, {}),
1200
- current: !creation && current === item,
1201
- separatorBefore: index === 0,
1202
- onSelect: () => {
1203
- setOpen(false);
1204
- if (creation || current !== item) change("switch", item.path);
1205
- },
1206
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1207
- className: "dsh-worktree-option",
1208
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.branch ?? text.detached }), !creation && current === item && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})]
1209
- })
1210
- }, item.path))]
1328
+ }, current.path)]
1329
+ })
1211
1330
  }),
1212
1331
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1213
1332
  id: status,
@@ -1241,13 +1360,22 @@ function apply(ctx) {
1241
1360
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text.errorCode }),
1242
1361
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: detailError.code }) })
1243
1362
  ] })] }),
1244
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("footer", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("footer", { children: [view && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1364
+ type: "button",
1365
+ "data-worktree-choose": true,
1366
+ disabled,
1367
+ onClick: () => {
1368
+ errorDialog.current?.close();
1369
+ setOpen(true);
1370
+ },
1371
+ children: text.choose
1372
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1245
1373
  type: "button",
1246
1374
  "data-worktree-retry": true,
1247
- disabled: loading || busy,
1375
+ disabled: loading,
1248
1376
  onClick: () => void load(true),
1249
1377
  children: loading ? text.loading : text.refresh
1250
- }) })
1378
+ })] })
1251
1379
  ]
1252
1380
  })
1253
1381
  ]