dsh-taskboard 0.6.2 → 0.6.4

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.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/controller
9
9
  */
10
- import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
10
+ import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
11
  import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
12
  import { emptyLedger } from '../shared/protocol.ts'
13
13
  import type { TaskboardClient } from './api.ts'
@@ -277,6 +277,12 @@ export class BoardController {
277
277
  return this.state.workspaces.find(w => w.id === workspaceId)?.gitAvailable === true
278
278
  }
279
279
 
280
+ /** How many repos a task mirror of this workspace would cover (0.6.3 mirror badge). */
281
+ repoCount(workspaceId: string | undefined): number {
282
+ if (workspaceId === undefined) return 1
283
+ return this.state.workspaces.find(w => w.id === workspaceId)?.repoCount ?? 1
284
+ }
285
+
280
286
  /**
281
287
  * Install the session-jump bridge (built from the runtime sessions service
282
288
  * by the client entry). Without it openSession reports 'unavailable'.
@@ -473,8 +479,8 @@ export class BoardController {
473
479
  }
474
480
  }
475
481
 
476
- /** Diff view (0.4.0): one execution's commit or changed path; errors surface via throw. */
477
- async fetchDiff(taskId: string, query: { execution: string; commit?: string; path?: string }): Promise<DiffResponse | undefined> {
482
+ /** Diff view (0.4.0): one execution's commit or changed path; `repo` picks a mirror repo (0.6.3). */
483
+ async fetchDiff(taskId: string, query: { execution: string; commit?: string; path?: string; repo?: string }): Promise<DiffResponse | undefined> {
478
484
  try {
479
485
  return await this.client.diff(taskId, query)
480
486
  } catch (error) {
@@ -520,13 +526,16 @@ export class BoardController {
520
526
 
521
527
  /**
522
528
  * ⇥ 合并 (detail page): merge the task branch into the main worktree.
523
- * @returns the outcome; `noop` means the branch had no new commits (nothing merged).
529
+ * @returns the outcome; `noop` means the branch had no new commits (nothing
530
+ * merged); multi-repo tasks (0.6.3) additionally carry per-repo `results`.
524
531
  */
525
- async mergeBranch(id: string): Promise<{ ok: true; noop?: boolean } | { ok: false; error: string }> {
532
+ async mergeBranch(id: string): Promise<{ ok: true; noop?: boolean; results?: MergeRepoResult[] } | { ok: false; error: string }> {
526
533
  try {
527
534
  const value = await this.client.mergeBranch(id)
528
535
  await this.refresh()
529
- return value.noop === true ? { ok: true, noop: true } : { ok: true }
536
+ return value.noop === true
537
+ ? { ok: true, noop: true }
538
+ : value.results !== undefined ? { ok: true, results: value.results } : { ok: true }
530
539
  } catch (error) {
531
540
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
532
541
  }
@@ -166,6 +166,9 @@ export const en: TaskboardDict = {
166
166
  'iso.merge.button': '⇥ Merge into main worktree',
167
167
  'iso.merge.failed': 'Merge failed: {error}',
168
168
  'iso.merge.noop': 'The branch has no commits ahead of the main worktree — nothing to merge (send it back to continue running, or clean it up)',
169
+ 'iso.merge.done': 'Merged per repo (--no-ff): {summary}',
170
+ 'iso.merge.partial': 'Some repos failed to merge: {summary}\n{error}',
171
+ 'iso.repo.root': 'Root repo',
169
172
  'iso.remove.wt': '🗑 Remove worktree',
170
173
  'iso.remove.wtTitle': 'git worktree remove (refused when uncommitted changes exist)',
171
174
  'iso.remove.wtb': '🗑 Remove worktree + branch',
@@ -290,6 +293,7 @@ export const en: TaskboardDict = {
290
293
  'form.iso.noneHintNonGit': 'Not a git repository; will run in the project directory',
291
294
  'form.iso.noneHint': 'No git; works directly in the project directory',
292
295
  'form.iso.nonGitNote': 'This project is not a git repository; the task will run in its directory (still created with the default configuration; the runtime degrades automatically)',
296
+ 'form.iso.mirrorNote': 'Multi-repo workspace: worktree isolation automatically mirrors all {n} repos — one task branch each, merged per repo at acceptance',
293
297
  'form.field.checklist': 'Acceptance checklist (DoD)',
294
298
  'form.field.checklistOptional': 'Acceptance checklist (DoD, optional)',
295
299
  'form.check.itemPlaceholder': 'Checklist item {n} (definition of done)',
@@ -361,7 +365,7 @@ export const en: TaskboardDict = {
361
365
  'set.subtitle': 'Global defaults for new tasks and session sync',
362
366
  'set.iso.heading': 'Default execution isolation',
363
367
  'set.iso.noneHint': 'No git; works directly in the project directory (factory default)',
364
- 'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others',
368
+ 'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others; multi-repo workspaces are mirrored whole (one branch per repo)',
365
369
  'set.iso.current': 'Currently saved default: {current}. Affects only tasks created hereafter; existing tasks keep their creation-time choice, and non-git projects still degrade to the project directory at run time.',
366
370
  'set.sync.heading': 'Auto-sync workspace sessions',
367
371
  'set.sync.off.name': '🚫 Sync off',
@@ -55,6 +55,24 @@ let unsubscribeService: (() => void) | undefined
55
55
  let snapshot: TaskboardLocaleSnapshot = { active: detectFallbackLocale(), revision: 0 }
56
56
  const listeners = new Set<() => void>()
57
57
 
58
+ // Late attach (issue #16): the taskboard client injects NOTHING, so it can
59
+ // activate BEFORE the locale service provides — and before the locale
60
+ // runtime syncs <html lang> (the server-rendered static value is "en").
61
+ // Until a real service attaches, a MutationObserver re-detects on every
62
+ // <html lang> change and a short poll re-tries ctx.get('locale').
63
+ let langObserver: MutationObserver | undefined
64
+ let retryTimer: ReturnType<typeof setInterval> | undefined
65
+
66
+ /** Stop every late-attach mechanism (a service took over, or dispose). */
67
+ function clearLateAttach(): void {
68
+ if (retryTimer !== undefined) {
69
+ clearInterval(retryTimer)
70
+ retryTimer = undefined
71
+ }
72
+ try { langObserver?.disconnect() } catch { /* observer already dead */ }
73
+ langObserver = undefined
74
+ }
75
+
58
76
  function isLocaleId(value: string): value is LocaleId {
59
77
  return value === 'zh' || value === 'en'
60
78
  }
@@ -80,6 +98,45 @@ function detectFallbackLocale(): LocaleId {
80
98
  return 'en'
81
99
  }
82
100
 
101
+ /** Coerce an unknown ctx value into a usable service face, else undefined. */
102
+ function asLocaleService(value: unknown): LocaleServiceFace | undefined {
103
+ const face = value as LocaleServiceFace | null | undefined
104
+ if (face === null || face === undefined || typeof face.getSnapshot !== 'function' || typeof face.subscribe !== 'function') return undefined
105
+ return face
106
+ }
107
+
108
+ /**
109
+ * Watch for the late locale activation while no service is attached
110
+ * (issue #16): re-detect on <html lang> changes (the locale runtime syncs
111
+ * it once up) and poll ctx.get('locale') briefly (inject ordering — the
112
+ * service may provide a moment after our apply).
113
+ */
114
+ function startLateAttach(retryGetLocale: (() => unknown) | undefined): void {
115
+ clearLateAttach()
116
+ try {
117
+ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
118
+ langObserver = new MutationObserver(() => {
119
+ if (service !== undefined) return // a real service owns the state
120
+ const next = detectFallbackLocale()
121
+ if (next !== snapshot.active) publish({ active: next, revision: snapshot.revision + 1 })
122
+ })
123
+ langObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
124
+ }
125
+ } catch { /* no DOM — the poll below still covers service-side activation */ }
126
+ if (retryGetLocale === undefined) return
127
+ let tries = 0
128
+ retryTimer = setInterval(() => {
129
+ tries += 1
130
+ let face: LocaleServiceFace | undefined
131
+ try { face = asLocaleService(retryGetLocale()) } catch { face = undefined }
132
+ if (face !== undefined) {
133
+ initI18n(face) // attaches the service and tears the late attach down
134
+ return
135
+ }
136
+ if (tries >= 8) clearLateAttach() // ~2s at 250ms — enough for boot ordering
137
+ }, 250)
138
+ }
139
+
83
140
  function publish(next: TaskboardLocaleSnapshot): void {
84
141
  if (next.active === snapshot.active && next.revision === snapshot.revision) return
85
142
  snapshot = next
@@ -89,18 +146,26 @@ function publish(next: TaskboardLocaleSnapshot): void {
89
146
  /**
90
147
  * Attach the DSH locale service (call from the client entry's apply with
91
148
  * ctx.get('locale')). Absent/malformed services are ignored — the fallback
92
- * detection stays in charge.
149
+ * detection stays in charge, and (issue #16) a late attach keeps watching:
150
+ * the locale service can provide AFTER our activation (inject ordering), so
151
+ * pass `retryGetLocale` to re-poll ctx.get('locale') for ~2s and pick the
152
+ * service up the moment it exists; <html lang> changes re-run detection in
153
+ * the meantime.
93
154
  * @param localeService - the ctx 'locale' service, when provided.
155
+ * @param retryGetLocale - re-lookup for the service (the client entry passes
156
+ * `() => ctx.get?.('locale')`); polled only while no service is attached.
94
157
  */
95
- export function initI18n(localeService: unknown): void {
96
- const face = localeService as LocaleServiceFace | null | undefined
97
- if (face === null || face === undefined || typeof face.getSnapshot !== 'function' || typeof face.subscribe !== 'function') {
158
+ export function initI18n(localeService: unknown, retryGetLocale?: () => unknown): void {
159
+ const face = asLocaleService(localeService)
160
+ if (face === undefined) {
98
161
  // No usable service: resolve the fallback NOW so a caller that inits
99
162
  // after the DOM is up gets the current detection, never a stale
100
- // module-load snapshot.
163
+ // module-load snapshot — then keep watching for the late activation.
101
164
  publish({ active: detectFallbackLocale(), revision: 0 })
165
+ startLateAttach(retryGetLocale)
102
166
  return
103
167
  }
168
+ clearLateAttach()
104
169
  service = face
105
170
  const sync = (): void => {
106
171
  try {
@@ -115,6 +180,7 @@ export function initI18n(localeService: unknown): void {
115
180
 
116
181
  /** Detach the service and return to fallback detection (tests, dispose). */
117
182
  export function disposeI18n(): void {
183
+ clearLateAttach()
118
184
  try { unsubscribeService?.() } catch { /* source already gone */ }
119
185
  unsubscribeService = undefined
120
186
  service = undefined
@@ -168,6 +168,9 @@ export const zh = {
168
168
  'iso.merge.button': '⇥ 合并到主工作区',
169
169
  'iso.merge.failed': '合并失败:{error}',
170
170
  'iso.merge.noop': '该分支没有领先主工作区的新提交,无需合并(可退回续跑或直接清理)',
171
+ 'iso.merge.done': '已按仓库合并(--no-ff):{summary}',
172
+ 'iso.merge.partial': '部分仓库合并失败:{summary}\n{error}',
173
+ 'iso.repo.root': '根仓库',
171
174
  'iso.remove.wt': '🗑 删除 worktree',
172
175
  'iso.remove.wtTitle': 'git worktree remove(有未提交修改时拒绝)',
173
176
  'iso.remove.wtb': '🗑 删 worktree + 分支',
@@ -292,6 +295,7 @@ export const zh = {
292
295
  'form.iso.noneHintNonGit': '当前项目非 git 仓库,将在原目录执行',
293
296
  'form.iso.noneHint': '不使用 git,直接在项目目录工作',
294
297
  'form.iso.nonGitNote': '当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)',
298
+ 'form.iso.mirrorNote': '多仓库工作区:Worktree 隔离将自动整区镜像 {n} 个仓库——每仓库独立任务分支,验收按仓库合并',
295
299
  'form.field.checklist': '验收清单(DoD)',
296
300
  'form.field.checklistOptional': '验收清单(DoD,可选)',
297
301
  'form.check.itemPlaceholder': '验收项 {n}(完成标准)',
@@ -363,7 +367,7 @@ export const zh = {
363
367
  'set.subtitle': '新建任务与会话同步的全局默认值',
364
368
  'set.iso.heading': '默认执行隔离',
365
369
  'set.iso.noneHint': '不使用 git,直接在项目目录工作(出厂默认)',
366
- 'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染',
370
+ 'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染;多仓库工作区自动整区镜像(每仓库独立分支)',
367
371
  'set.iso.current': '当前保存的默认:{current}。仅影响之后新建的任务;已有任务保持创建时的选择,非 git 项目运行时仍自动降级原目录。',
368
372
  'set.sync.heading': '自动同步工作区会话',
369
373
  'set.sync.off.name': '🚫 关闭同步',
@@ -70,8 +70,11 @@ export function apply(ctx: ClientContextFace): void {
70
70
  injectStyles()
71
71
  // Locale source (设置 → 通用设置 → 语言): soft-attached — absent on
72
72
  // compositions without the DSH locale plugin, where the fallback
73
- // (<html lang> / navigator) takes over. Never a hard inject.
74
- initI18n(ctx.get?.('locale'))
73
+ // (<html lang> / navigator) takes over. Never a hard inject. The getter
74
+ // rides along (issue #16): our client bundle activates with zero service
75
+ // deps, potentially BEFORE the locale service provides — the runtime
76
+ // re-polls it for ~2s and watches <html lang> until it does.
77
+ initI18n(ctx.get?.('locale'), () => ctx.get?.('locale'))
75
78
  const client = createClient()
76
79
  const controller = new BoardController(client)
77
80
 
@@ -622,6 +622,13 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
622
622
  .dsh-atb-isolation-note { display: block; margin-top: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); }
623
623
  .dsh-atb-mode-picker[data-disabled="true"] .dsh-atb-mode-opt { cursor: not-allowed; opacity: .55; }
624
624
  .dsh-atb-iso-none { font-size: 12.5px; color: var(--dsw-alias-label-secondary, inherit); }
625
+ .dsh-atb-iso-repo[data-multi="true"] { padding: 6px 0 2px; border-top: 1px dashed var(--dsw-alias-border-secondary, #ddd2); }
626
+ .dsh-atb-iso-repo[data-multi="true"]:first-of-type { border-top: none; padding-top: 0; }
627
+ .dsh-atb-iso-repohead { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 6px; align-items: baseline; }
628
+ .dsh-atb-iso-repohead code {
629
+ font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; font-weight: 600;
630
+ color: var(--dsw-alias-state-business-primary, #3e63dd);
631
+ }
625
632
  .dsh-atb-iso-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 8px; }
626
633
  .dsh-atb-iso-fact { font-size: 11.5px; color: var(--dsw-alias-label-secondary, inherit); }
627
634
  .dsh-atb-iso-fact b { font-weight: 600; color: var(--dsw-alias-state-business-primary, #3e63dd); }