@loomhelm/multi-task-flow 0.2.8 → 0.2.9

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.
Files changed (59) hide show
  1. package/README.md +14 -14
  2. package/package.json +2 -2
  3. package/src/cli/service.ts +26 -0
  4. package/src/cli.ts +3 -1
  5. package/toolkit/artifact-manifest.json +67 -71
  6. package/toolkit/package.json +1 -1
  7. package/toolkit/skills/bin/check_source_policy.ts +13 -7
  8. package/toolkit/skills/bin/multi_task_platform_gate.ts +2 -2
  9. package/toolkit/skills/bin/tests/core/package_scripts.test.ts +1 -1
  10. package/toolkit/skills/bin/tests/plan/multi-task-flow.test.ts +3 -3
  11. package/toolkit/skills/catalog.json +0 -1
  12. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/data-and-scripts.md +8 -6
  13. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/execution.md +1 -1
  14. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/plan-revision-and-change-audit.md +1 -1
  15. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/web-template.md +1 -1
  16. package/toolkit/skills/docs/vendor/dot-codex/specs/a4ge9-stable-preview-runtime/IMPLEMENTATION.md +0 -2
  17. package/toolkit/skills/docs/vendor/dot-codex/specs/a4ge9-stable-preview-runtime/SPEC.md +0 -1
  18. package/toolkit/skills/docs/vendor/dot-codex/specs/dev-plan-standalone-typescript-finalizer/IMPLEMENTATION.md +1 -1
  19. package/toolkit/skills/docs/vendor/dot-codex/specs/multi-task-flow/IMPLEMENTATION.md +3 -3
  20. package/toolkit/skills/docs/vendor/dot-codex/specs/multi-task-flow/SCHEMA.md +4 -4
  21. package/toolkit/skills/docs/vendor/dot-codex/specs/stable-preview-runtime/IMPLEMENTATION.md +0 -2
  22. package/toolkit/skills/docs/vendor/dot-codex/specs/stable-preview-runtime/SPEC.md +0 -1
  23. package/toolkit/skills/multi-task-flow/SKILL.md +65 -47
  24. package/toolkit/skills/multi-task-flow/dist/app.css +1 -1
  25. package/toolkit/skills/multi-task-flow/dist/app.js +74 -74
  26. package/toolkit/skills/multi-task-flow/dist/index.html +3 -3
  27. package/toolkit/skills/multi-task-flow/lib/atomic-orchestration.ts +1 -0
  28. package/toolkit/skills/multi-task-flow/lib/engine.ts +65 -7
  29. package/toolkit/skills/multi-task-flow/lib/package-source.ts +5 -5
  30. package/toolkit/skills/multi-task-flow/lib/schema.ts +82 -2
  31. package/toolkit/skills/multi-task-flow/lib/service-pages.ts +1 -26
  32. package/toolkit/skills/multi-task-flow/lib/view.ts +16 -0
  33. package/toolkit/skills/multi-task-flow/references/shared-viewer.md +1 -1
  34. package/toolkit/skills/multi-task-flow/scripts/check-client-skills.ts +7 -4
  35. package/toolkit/skills/multi-task-flow/scripts/check-skills-installation.ts +14 -8
  36. package/toolkit/skills/multi-task-flow/scripts/import-client-skills.ts +1 -1
  37. package/toolkit/skills/multi-task-flow/scripts/package-client-skills.ts +4 -4
  38. package/toolkit/skills/multi-task-flow/scripts/test-viewer-source.ts +1 -1
  39. package/toolkit/skills/multi-task-flow/tests/atomic-orchestration-schema.test.ts +5 -0
  40. package/toolkit/skills/multi-task-flow/tests/browser/README.md +1 -1
  41. package/toolkit/skills/multi-task-flow/tests/check-skills-installation.test.ts +12 -38
  42. package/toolkit/skills/multi-task-flow/tests/delivery-topology.test.ts +33 -0
  43. package/toolkit/skills/multi-task-flow/tests/orchestrate.test.ts +3 -5
  44. package/toolkit/skills/multi-task-flow/tests/ui/details-content.test.ts +9 -0
  45. package/toolkit/skills/multi-task-flow/tests/ui/responsive-width-contract.test.ts +25 -1
  46. package/toolkit/skills/multi-task-flow/tests/ui/task-context-menu.test.ts +2 -1
  47. package/toolkit/skills/multi-task-flow/web/app.css +64 -9
  48. package/toolkit/skills/multi-task-flow/web/app.ts +33 -14
  49. package/toolkit/skills/multi-task-flow/web/atomic-graph-view.tsx +1 -1
  50. package/toolkit/skills/multi-task-flow/web/details-content.ts +49 -38
  51. package/toolkit/skills/multi-task-flow/web/graph-view.tsx +1 -1
  52. package/toolkit/skills/multi-task-flow/web/index.html +3 -3
  53. package/toolkit/skills/multi-task-flow/web/model.ts +1 -1
  54. package/toolkit/skills/multi-task-flow/web/timeline.ts +11 -8
  55. package/toolkit/skills/package.json +2 -2
  56. package/toolkit/skills/source-manifest.json +691 -398
  57. package/toolkit/skills/support-manifest.json +1 -0
  58. package/toolkit/skills/multi-task-flow/scripts/serve.ts +0 -218
  59. package/toolkit/skills/multi-task-flow/tests/serve.test.ts +0 -194
@@ -1,45 +1,19 @@
1
1
  import assert from 'node:assert/strict';
2
- import { spawnSync } from 'node:child_process';
3
- import { cpSync, existsSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
4
3
  import { tmpdir } from 'node:os';
5
4
  import { join, resolve } from 'node:path';
6
5
  import { test } from 'bun:test';
7
- import { checkSkillsInstallation } from '../scripts/check-skills-installation.ts';
8
6
 
9
- test('a source-only skills installation runs official TS entrypoints and detects missing resources', () => {
7
+ test('the installable Skill tree excludes npm runtime resources', () => {
10
8
  const isolated = realpathSync(mkdtempSync(join(tmpdir(), 'loomhelm-skills-only-')));
11
- const source = resolve(import.meta.dirname, '../..');
12
- const manifest = JSON.parse(readFileSync(join(source,'source-manifest.json'),'utf8'));
13
- const sources = new Set([...manifest.files,...manifest.additions].map(file => resolve(source,'..',file.path)));
9
+ const source = resolve(import.meta.dirname, '../../multi-task-flow');
14
10
  try {
15
- cpSync(source, join(isolated,'skills'), { recursive:true, filter:path => !['node_modules','.cache','dist'].includes(path.split('/').at(-1)) && !path.endsWith('.node') && (!path.endsWith('.js') || sources.has(path)) });
16
- const unprepared = checkSkillsInstallation(isolated,{probe:true});
17
- assert.equal(unprepared.sourceComplete,true);
18
- assert.equal(unprepared.ready,false); assert.equal(unprepared.ok,false);
19
- assert.equal(unprepared.code,'SKILLS_RUNTIME_DEPENDENCY_MISSING');
20
- assert.deepEqual(unprepared.dependencies.missing.sort(),['sharp','zod']);
21
- assert.equal(unprepared.entrypoints.status,'blocked');
22
- const install = spawnSync(process.execPath, ['install','--cwd',join(isolated,'skills'),'--frozen-lockfile','--ignore-scripts','--backend=copy'], {env:process.env,encoding:'utf8'});
23
- assert.equal(install.status,0,install.stderr);
24
- const report = checkSkillsInstallation(isolated,{probe:true});
25
- assert.equal(report.ok,true,JSON.stringify(report)); assert.equal(report.ready,true);
26
- assert.equal(report.skills,29); assert.deepEqual(report.requiredSparsePaths,['skills']);
27
- assert.equal(existsSync(join(isolated,'bin')),false);
28
- assert.equal(existsSync(join(isolated,'skills/bin/plan_review_view.js')),false);
29
- assert.equal(existsSync(join(isolated,'skills/bin/native/posix.node')),false);
30
- const plan=readFileSync(join(isolated,'skills/dev-plan/references/plan-cases/positive-fast-track.md'),'utf8');
31
- const finalized=spawnSync('/bin/bash',['-p',join(isolated,'skills/bin/plan-cli.sh'),'--input','-'],{cwd:source,env:process.env,input:plan,encoding:'utf8',timeout:30000});
32
- assert.equal(finalized.status,0,finalized.stderr); assert.match(finalized.stdout,/<proposed_plan>/);
33
- const context=join(isolated,'skills/dot-codex-development/scripts/context.ts');
34
- const original=readFileSync(context); writeFileSync(context,'// tampered');
35
- assert.equal(checkSkillsInstallation(isolated).sourceComplete,false);
36
- writeFileSync(context,original);
37
- renameSync(context,join(isolated,'context.ts')); symlinkSync(join(isolated,'context.ts'),context);
38
- assert.equal(checkSkillsInstallation(isolated).ok,false);
39
- rmSync(context); renameSync(join(isolated,'context.ts'),context);
40
- renameSync(join(isolated,'skills/bin'),join(isolated,'bin'));
41
- const broken=checkSkillsInstallation(isolated,{probe:true});
42
- assert.equal(broken.ok,false); assert.equal(broken.ready,false);
43
- assert.ok(broken.missing.includes('skills/bin/plan_review_view.ts'));
44
- } finally { rmSync(isolated,{recursive:true,force:true}); }
45
- },60000);
11
+ cpSync(source, join(isolated, 'multi-task-flow'), { recursive: true });
12
+ assert.equal(existsSync(join(isolated, 'multi-task-flow', 'SKILL.md')), true);
13
+ for (const name of ['data', 'dist', 'examples', 'lib', 'scripts', 'tests', 'web', 'package.json', 'bun.lock']) {
14
+ assert.equal(existsSync(join(isolated, 'multi-task-flow', name)), false, name);
15
+ }
16
+ } finally {
17
+ rmSync(isolated, { recursive: true, force: true });
18
+ }
19
+ }, 60000);
@@ -0,0 +1,33 @@
1
+ import { expect, test } from "bun:test";
2
+ import { deliveryTopologyForGroup, validateDeliveryTopologies } from "../lib/schema";
3
+
4
+ const task = (id: string, group_id: string, policy: "shared_delivery_unit" | "child_pr") => ({
5
+ id,
6
+ group_id,
7
+ kind: "contribution" as const,
8
+ disposition: "active" as const,
9
+ execution_owner: policy === "shared_delivery_unit" ? "delivery_agent" as const : "task_agent" as const,
10
+ conversation_policy: policy === "shared_delivery_unit" ? "delivery_owned" as const : "task_agent" as const,
11
+ delivery_policy: policy,
12
+ });
13
+
14
+ test("derives shared topology from one contribution task", () => {
15
+ const tasks = [task("task-a", "du-a", "shared_delivery_unit")];
16
+ expect(deliveryTopologyForGroup(tasks as any, "du-a")).toBe("shared_delivery");
17
+ expect(() => validateDeliveryTopologies(tasks as any, true)).not.toThrow();
18
+ });
19
+
20
+ test("derives multi-task topology without allowing shared policies", () => {
21
+ const tasks = [task("task-a", "du-a", "child_pr"), task("task-b", "du-a", "child_pr")];
22
+ expect(deliveryTopologyForGroup(tasks as any, "du-a")).toBe("multi_task_integration");
23
+ expect(() => validateDeliveryTopologies(tasks as any, true)).not.toThrow();
24
+ expect(() => validateDeliveryTopologies([task("task-a", "du-a", "shared_delivery_unit"), task("task-b", "du-a", "child_pr")] as any, true)).toThrow("multi-task delivery unit");
25
+ });
26
+
27
+ test("does not count verification tasks when deriving topology", () => {
28
+ const tasks = [
29
+ task("task-a", "du-a", "shared_delivery_unit"),
30
+ { id: "verify-a", group_id: "du-a", kind: "verification", disposition: "active" },
31
+ ];
32
+ expect(deliveryTopologyForGroup(tasks as any, "du-a")).toBe("shared_delivery");
33
+ });
@@ -627,7 +627,7 @@ describe("orchestrate init", () => {
627
627
  });
628
628
 
629
629
  test("documents init as an explicit mutation boundary", () => {
630
- const skill = readFileSync(join(import.meta.dir, "../SKILL.md"), "utf8");
630
+ const skill = readFileSync(join(import.meta.dir, "../../multi-task-flow/SKILL.md"), "utf8");
631
631
  const design = readFileSync(
632
632
  join(
633
633
  import.meta.dir,
@@ -636,10 +636,8 @@ describe("orchestrate init", () => {
636
636
  "utf8",
637
637
  );
638
638
 
639
- expect(skill).toContain("Initialization is a mutating runtime action");
640
- expect(skill).toContain(
641
- "must not call `init` or a native conversation-creation tool",
642
- );
639
+ expect(skill).toContain("初始化是有副作用的运行时操作");
640
+ expect(skill).toContain("只读审查、诊断、代码探索或仅制定计划时不得调用本 Skill");
643
641
  expect(design).toContain("失败返回 `INVALID_INITIAL_STATE`");
644
642
  expect(design).toContain("不创建任务会话");
645
643
  });
@@ -22,6 +22,15 @@ describe("dashboard details content", () => {
22
22
  expect(document.querySelector(".detail-duration")?.textContent).toBe("未知");
23
23
  expect(duration(details.groupPreview(group))).toBe("未知");
24
24
  });
25
+ test("group details include the full Agent output at the end", () => {
26
+ const group = { id: "core", title: "核心", state: "closed", agent_thread_id: "thread-group", parent_pr: { url: null }, latest_agent_output: { thread_id: "thread-group", agent_state: "idle", stale: false, state: "available", text: "交付单元已完成", turn_id: null, message_id: null, phase: null, message_status: null, observed_at: null } } as Group;
27
+ const view = { groups: [group], tasks: [], actual_intervals: [] } as unknown as View;
28
+ const details = createDetailContent({ getData: () => view, labels: {}, labelTask: () => "", time: () => "", safeExternal: () => null });
29
+ details.showGroup(group);
30
+ const labels = [...document.querySelectorAll("#details-content .detail-label")].map((node) => node.textContent);
31
+ expect(labels.at(-1)).toBe("Agent 输出");
32
+ expect(document.querySelector(".detail-agent-output")?.textContent).toContain("交付单元已完成");
33
+ });
25
34
  test("keeps the summary outside the scroll body and preserves only the same object's reading position", () => {
26
35
  const group = { id: "same", title: "交付单元", state: "closed", parent_pr: { url: null } } as Group;
27
36
  const view = { groups: [group], tasks: [], actual_intervals: [] } as unknown as View;
@@ -17,11 +17,35 @@ test("medium details stay in the static bottom layout instead of a side-by-side
17
17
  expect(medium).toContain("border: 0; border-top: 1px solid var(--border); border-radius: 0; background: transparent;");
18
18
  expect(medium).toContain(".details-heading button { display: none; }");
19
19
  expect(medium).toContain(".details-slot { position: static; padding-top: 16px; }");
20
- expect(medium).toContain(".details-heading { padding: 0 0 12px; display: flex; align-items: baseline;");
20
+ expect(medium).toContain(".details-heading { padding: 0 0 12px; display: flex; align-items: center;");
21
+ expect(medium).toContain("#details-summary { min-width: 0; display: flex; align-items: center; gap: 12px; }");
22
+ expect(medium).toContain(".detail-title { min-width: 0; margin: 0; padding-right: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }");
23
+ expect(medium).toContain(".detail-state { flex: 0 0 auto; margin: 0; white-space: nowrap; }");
21
24
  expect(medium).toContain("#details-content { padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));");
25
+ expect(medium).toContain("grid-auto-rows: max-content; align-content: start; align-items: start;");
26
+ expect(medium).toContain("#details-content[data-detail-kind=\"task\"] { grid-template-columns: minmax(220px, .9fr) repeat(3, minmax(0, 1fr)); }");
27
+ expect(medium).toContain("#details-content[data-detail-kind=\"group\"] { grid-template-columns: minmax(260px, 1.25fr) repeat(3, minmax(0, 1fr)); }");
28
+ expect(medium).toContain("#details-content > .detail-field:has(.atom-list) { grid-column: span 1; }");
29
+ expect(medium).toContain("#details-content > .detail-field:has(.atom-list) { display: block; padding-block: 7px; }");
30
+ expect(medium).toContain("text-overflow: ellipsis; white-space: nowrap;");
31
+ expect(medium).toContain("#details-content > .detail-group-facts { grid-column: 2 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));");
32
+ expect(medium).toContain("#details-content > .detail-group-facts > .detail-field { min-width: 0; padding-block: 7px; display: block; }");
22
33
  expect(medium).toContain('.workspace[data-view-tab="tasks"] .details-slot { grid-column: 1; grid-row: 3; }');
23
34
  });
24
35
 
36
+ test("tall medium details provide an independently scrollable content body", () => {
37
+ const tall = css.split("@media (min-width: 768px) and (max-width: 1444px) and (min-height: 961px) {")[1]!.split("@media (min-width: 768px) and (max-width: 1444px) and (max-height: 960px)")[0]!;
38
+ expect(tall).toContain("display: flex; flex-direction: column;");
39
+ expect(tall).toContain(".workspace:not(.has-details) { grid-template-rows: minmax(240px, 1fr); }");
40
+ expect(tall).toContain(".workspace[data-view-tab=\"tasks\"]:not(.has-details) { grid-template-rows: auto minmax(240px, 1fr); }");
41
+ expect(tall).toContain("#details-content { flex: 1 1 auto; min-height: 0; overflow: auto; }");
42
+ expect(tall).toContain(".workspace-view > .timeline-view { min-height: 0; display: flex; flex-direction: column; }");
43
+ expect(tall).toContain("height: auto !important; min-height: 0;");
44
+ expect(tall).toContain(".workspace-view > .canvas-view #graph { height: 100% !important; min-height: 0; }");
45
+ expect(tall).toContain(".workspace-view > .canvas-view #atomic-graph { height: 100% !important; min-height: 0; }");
46
+ expect(tall).toContain("overflow-anchor: none;");
47
+ });
48
+
25
49
  test("identity actions follow the project name instead of filling the title row", () => {
26
50
  expect(css).toContain(".identity-copy { flex: 0 1 auto; min-width: 0; }");
27
51
  expect(css).toContain("text-overflow: ellipsis; white-space: nowrap;");
@@ -70,5 +70,6 @@ test("keyboard opening, navigation and Escape; blank space retains native menu",
70
70
  });
71
71
  test("authentication redirects only accept same-service run links and legal view state", () => {
72
72
  expect(parseViewerTarget('/runs/run-a/?selected=task%3Atask.v2&detail=1&plan=plan.v2&q=hidden', 'http://localhost', { allowHome: false })?.target).toBe('/runs/run-a/?selected=task%3Atask.v2&detail=1&plan=plan.v2&q=hidden');
73
- for (const value of ['https://evil.test/runs/run-a/?selected=task:C2', '//evil.test/runs/run-a/?selected=task:C2', '/login?selected=task:C2', '/runs/run-a/../', '/runs/%2e%2e/', '/runs\\run-a/', '/runs/run-a/?selected=other:C2', '/runs/run-a/?selected=task:<script>', 'http://user:pass@localhost/runs/run-a/?selected=task:C2', '/runs/run-a/?ticket=secret', '/runs/run-a/?selected=task:C2&selected=task:C3', '/runs/run-a/?q=%0d%0aLocation:%20https://evil.test']) expect(parseViewerTarget(value, 'http://localhost', { allowHome: false })).toBeNull();
73
+ expect(parseViewerTarget('/runs/run-a/?tab=tasks&selected=group%3AG2&detail=1&future=ignored', 'http://localhost', { allowHome: false })?.target).toContain('tab=tasks');
74
+ for (const value of ['https://evil.test/runs/run-a/?selected=task:C2', '//evil.test/runs/run-a/?selected=task:C2', '/login?selected=task:C2', '/runs/run-a/../', '/runs/%2e%2e/', '/runs\\run-a/', 'http://user:pass@localhost/runs/run-a/?selected=task:C2', '/runs/run-a/?ticket=secret', '/runs/run-a/?q=%0d%0aLocation:%20https://evil.test']) expect(parseViewerTarget(value, 'http://localhost', { allowHome: false })).toBeNull();
74
75
  });
@@ -191,11 +191,11 @@ h1 { margin: 3px 0 0; color: var(--text); font-size: 26px; line-height: 1.14; fo
191
191
  .summary-label { align-self: end; color: var(--muted); font-size: 11px; }
192
192
  .summary-item strong { align-self: start; color: var(--text); font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 21px; line-height: 1.05; font-weight: 400; font-variant-numeric: tabular-nums; /* taste-skill-ignore: changing counts align in a technical face. */ }
193
193
 
194
- .workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 336px; gap: var(--gap); min-height: 610px; margin-top: 24px; }
194
+ .workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 336px; gap: var(--gap); min-height: 610px; margin-top: 24px; overflow-anchor: none; }
195
195
  .workspace:not(.has-details) { grid-template-columns: minmax(0, 1fr); }
196
196
  .workspace-main { min-width: 0; }
197
197
  .workspace-view, .workspace-tasks { min-width: 0; }
198
- .view-header { min-height: 44px; display: grid; grid-template-columns: 148px auto minmax(190px, 260px); align-items: stretch; gap: 18px; border-bottom: 1px solid var(--border); background: transparent; }
198
+ .view-header { min-height: 44px; display: grid; grid-template-columns: 148px minmax(0, 1fr) minmax(190px, 260px) auto; align-items: stretch; gap: 18px; border-bottom: 1px solid var(--border); background: transparent; }
199
199
  .view-heading { min-width: 0; padding: 7px 16px 0 0; border-right: 1px solid var(--rule); }
200
200
  .view-context { display: block; margin-top: 1px; color: var(--text); font-size: 13px; font-weight: 500; }
201
201
  .view-count { display: block; margin-top: 1px; color: var(--faint); font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; /* taste-skill-ignore: compact counts belong to the control plane. */ }
@@ -208,6 +208,8 @@ h1 { margin: 3px 0 0; color: var(--text); font-size: 26px; line-height: 1.14; fo
208
208
  .search svg { position: absolute; left: 11px; width: 14px; height: 14px; flex: none; pointer-events: none; }
209
209
  .search input { width: 100%; min-width: 0; min-height: 30px; padding: 0 12px 0 33px; border: 0; border-radius: inherit; outline: 0; background: transparent; color: var(--text); }
210
210
  .search input::placeholder { color: var(--faint); }
211
+ .task-view-tools { display: none; align-items: center; justify-self: end; gap: 12px; white-space: nowrap; }
212
+ .workspace[data-view-tab="tasks"] .task-view-tools { display: flex; }
211
213
  .filters { min-height: 56px; padding: 12px 0; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--rule); background: transparent; }
212
214
  .filter-caption { margin-right: 3px; color: var(--faint); font-size: 11px; }
213
215
  .filters > .menu-control { position: relative; display: block; max-width: 180px; }
@@ -264,11 +266,14 @@ h1 { margin: 3px 0 0; color: var(--text); font-size: 26px; line-height: 1.14; fo
264
266
  .graph-a11y-node:hover, .graph-a11y-node[aria-pressed="true"] { background: var(--active); }
265
267
  .empty { display: grid; place-items: center; min-height: 260px; color: var(--faint); }
266
268
  .empty[hidden], [hidden] { display: none !important; }
267
- .timeline-view { min-height: 360px; padding: 12px; }
269
+ .timeline-view { min-height: 360px; padding: 12px; overflow: hidden; overscroll-behavior: contain; }
268
270
  #planned-timeline, #actual-timeline { height: 284px; }
269
271
  .timeline-view .vis-timeline { height: 100% !important; border: 0; font-family: inherit; user-select: none; }
270
272
  .timeline-view .vis-loading-screen { display: none !important; }
271
- .timeline-view .vis-panel.vis-center { cursor: grab; touch-action: pan-y; }
273
+ .timeline-view .vis-panel.vis-center { cursor: grab; touch-action: pan-x pan-y !important; overflow: auto !important; scrollbar-width: none; }
274
+ .timeline-view .vis-panel.vis-center::-webkit-scrollbar { display: none; }
275
+ .timeline-view .vis-panel.vis-left { cursor: grab; touch-action: pan-x pan-y !important; overflow: hidden !important; }
276
+ .timeline-view .vis-panel.vis-center { overscroll-behavior: contain; }
272
277
  .timeline-view .vis-panel.vis-center:active { cursor: grabbing; }
273
278
  .timeline-view .vis-panel.vis-center.is-panning { cursor: grabbing; }
274
279
  .timeline-view .vis-panel, .timeline-view .vis-labelset .vis-label, .timeline-view .vis-foreground .vis-group { border-color: var(--rule); background: transparent; }
@@ -351,6 +356,7 @@ h1 { margin: 3px 0 0; color: var(--text); font-size: 26px; line-height: 1.14; fo
351
356
  @keyframes task-status-hourglass { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }
352
357
 
353
358
  .details-slot { min-width: 0; align-self: stretch; }
359
+ .details-resize { display: none; }
354
360
  .details { position: fixed; top: var(--details-top, 16px); left: var(--details-left); width: var(--details-width); max-height: calc(100dvh - var(--details-top, 16px) - 16px); z-index: 10; display: flex; flex-direction: column; overflow: hidden; min-width: 0; container: detail-panel / inline-size; --details-padding-inline: 20px; --details-padding-bottom: 24px; padding: 0; background: var(--raised); border: 1px solid var(--border); border-radius: var(--radius-card); }
355
361
  .details-heading { position: relative; flex: none; padding-inline: var(--details-padding-inline); }
356
362
  #details-content { padding: 0 var(--details-padding-inline) var(--details-padding-bottom); min-height: 0; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-gutter: stable; }
@@ -421,6 +427,9 @@ time[data-local-timestamp] { cursor: help; font-variant-numeric: tabular-nums; }
421
427
  @media (min-width: 768px) and (max-width: 1444px) {
422
428
  html { scrollbar-gutter: stable; }
423
429
  .main { container-type: inline-size; }
430
+ .view-header { min-height: 86px; grid-template-rows: minmax(86px, auto); }
431
+ .view-heading { padding-top: 7px; padding-bottom: 7px; }
432
+ .tabs { min-height: 52px; }
424
433
  .workspace {
425
434
  --detail-min-width: 243px;
426
435
  --detail-max-width: 740px;
@@ -438,10 +447,23 @@ time[data-local-timestamp] { cursor: help; font-variant-numeric: tabular-nums; }
438
447
  .details-slot { position: static; padding-top: 16px; }
439
448
  .details { position: static; width: auto; max-height: none; border: 0; border-top: 1px solid var(--border); border-radius: 0; background: transparent; box-shadow: none; padding: 16px 0 4px; display: block; }
440
449
  .details-heading button { display: none; }
441
- .details-heading { padding: 0 0 12px; display: flex; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--rule); }
442
- .detail-title { margin-top: 0; }
443
- #details-content { padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: 20px; }
444
- #details-content > .detail-field:has(.detail-agent-output), #details-content > .detail-field:has(.detail-empty-output), #details-content > .detail-field:has(.atom-list) { grid-column: 1 / -1; }
450
+ .details-heading { padding: 0 0 12px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--rule); }
451
+ #details-summary { min-width: 0; display: flex; align-items: center; gap: 12px; }
452
+ .detail-title { min-width: 0; margin: 0; padding-right: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
453
+ .detail-state { flex: 0 0 auto; margin: 0; white-space: nowrap; }
454
+ #details-content { padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: max-content; align-content: start; align-items: start; column-gap: 20px; }
455
+ #details-content[data-detail-kind="task"] { grid-template-columns: minmax(220px, .9fr) repeat(3, minmax(0, 1fr)); }
456
+ #details-content[data-detail-kind="group"] { grid-template-columns: minmax(260px, 1.25fr) repeat(3, minmax(0, 1fr)); }
457
+ #details-content > .detail-field:has(.detail-agent-output), #details-content > .detail-field:has(.detail-empty-output) { grid-column: 1 / -1; }
458
+ #details-content > .detail-field:has(.atom-list) { grid-column: span 1; }
459
+ #details-content > .detail-field:has(.atom-list) { display: block; padding-block: 7px; }
460
+ #details-content > .detail-field:has(.atom-list) > .detail-value { margin-top: 6px; }
461
+ #details-content > .detail-field:has(.atom-list) .status-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
462
+ #details-content > .detail-group-facts { grid-column: 2 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-content: start; align-items: start; column-gap: 16px; }
463
+ #details-content > .detail-group-facts > .detail-field { min-width: 0; padding-block: 7px; display: block; }
464
+ #details-content > .detail-group-facts > .detail-field > .detail-label { display: block; margin-bottom: 4px; }
465
+ #details-content > .detail-group-facts > .detail-field > .detail-value { display: block; }
466
+ #details-content > .detail-group-facts > .detail-field:has(.detail-agent-output), #details-content > .detail-group-facts > .detail-field:has(.detail-empty-output) { grid-column: 1 / -1; }
445
467
  .details-backdrop { display: none; }
446
468
  .workspace .task-layout-grid, .workspace .group-cards {
447
469
  grid-template-columns: repeat(auto-fill, var(--stable-task-card-width)); gap: 20px;
@@ -452,6 +474,38 @@ time[data-local-timestamp] { cursor: help; font-variant-numeric: tabular-nums; }
452
474
  .task-layout-board > .task-column { flex-basis: auto; min-width: 0; }
453
475
  }
454
476
 
477
+ @media (min-width: 768px) and (max-width: 1444px) and (min-height: 961px) {
478
+ .workspace { height: max(610px, calc(100dvh - 190px)); max-height: none; min-height: 610px; overflow: visible; grid-template-rows: minmax(240px, 1fr) var(--details-height, 40vh); }
479
+ .workspace[data-view-tab="tasks"] { grid-template-rows: auto minmax(240px, 1fr) var(--details-height, 40vh); }
480
+ .workspace:not(.has-details) { grid-template-rows: minmax(240px, 1fr); }
481
+ .workspace[data-view-tab="tasks"]:not(.has-details) { grid-template-rows: auto minmax(240px, 1fr); }
482
+ .workspace-view, .workspace-tasks { min-height: 0; align-self: stretch; overflow: visible; }
483
+ .workspace-view { display: flex; flex-direction: column; }
484
+ .workspace-view > .canvas-view, .workspace-view > .timeline-view { flex: 1 1 auto; min-height: 0; margin-bottom: 0; }
485
+ .workspace-view > .canvas-view #graph { height: 100% !important; min-height: 0; }
486
+ .workspace-view > .canvas-view #atomic-graph { height: 100% !important; min-height: 0; }
487
+ .workspace-view > .timeline-view { min-height: 0; display: flex; flex-direction: column; }
488
+ .workspace-view > .timeline-view > #planned-timeline, .workspace-view > .timeline-view > #actual-timeline { flex: 1 1 auto; height: auto !important; min-height: 0; }
489
+ .workspace-view > .timeline-view > .axis-timezone, .workspace-view > .timeline-view > .unknown { flex: none; }
490
+ .workspace-tasks { overflow-y: auto; overflow-x: hidden; }
491
+ .details-slot { grid-row: 2 !important; padding-top: 0; min-height: 0; }
492
+ .workspace[data-view-tab="tasks"] .details-slot { grid-row: 3 !important; }
493
+ .details-resize { display: block; height: 12px; cursor: ns-resize; border-top: 1px solid var(--border); background: linear-gradient(var(--control), var(--control)) center / 42px 3px no-repeat; touch-action: none; overflow-anchor: none; }
494
+ .details-resize:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
495
+ .details { height: calc(var(--details-height, 40vh) - 12px); max-height: none; padding-top: 0; overflow: hidden; display: flex; flex-direction: column; }
496
+ .details-heading { flex: none; }
497
+ #details-content { flex: 1 1 auto; min-height: 0; overflow: auto; }
498
+ }
499
+
500
+ @media (min-width: 768px) and (max-width: 1444px) and (max-height: 960px) {
501
+ .details { position: fixed; top: auto; right: 0; bottom: 0; left: 0; width: auto; max-height: min(82dvh, calc(100dvh - 16px)); padding: 18px 0 0; border-width: 1px 0 0; border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; box-shadow: 0 -12px 32px rgba(0, 0, 0, .24); transform: translateY(102%); }
502
+ .details.open { transform: translateY(0); }
503
+ .details::before { flex: none; width: 42px; height: 3px; margin: -7px auto 14px; display: block; border-radius: 2px; content: ""; background: var(--control); }
504
+ .details-resize { display: none; }
505
+ .details-backdrop { position: fixed; inset: 0; z-index: 15; display: block; background: #0d101488; pointer-events: auto; }
506
+ .details-backdrop[hidden] { display: none; }
507
+ }
508
+
455
509
  @media (max-width: 767px) {
456
510
  .context-menu-mobile-trigger { display: inline-grid; }
457
511
  .topbar { min-height: 50px; padding: 12px 16px; flex-wrap: wrap; gap: 12px; }
@@ -562,7 +616,8 @@ time[data-local-timestamp] { cursor: help; font-variant-numeric: tabular-nums; }
562
616
  .profile-caption { margin: 0; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; color: var(--muted); }
563
617
  .profile-error { margin: 0; font-size: 12px; color: var(--negative); overflow-wrap: anywhere; }
564
618
  .profile-error:empty { display: none; }
565
- .detail-field:has(.execution-profile-editor) { grid-template-columns: minmax(0, 1fr); gap: 7px; }
619
+ .detail-field:has(.execution-profile-editor) { align-items: center; }
620
+ .detail-field:has(.execution-profile-editor) .execution-profile-editor { width: fit-content; max-width: 100%; }
566
621
  .run-profile-control { flex: 0 0 auto; min-width: 0; margin-left: -1px; }
567
622
  .run-profile-control .profile-trigger { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; min-height: 30px; padding: 0; border: 0; border-color: transparent; border-radius: 50%; color: var(--muted); background: transparent; }
568
623
  .run-profile-control .profile-trigger svg { width: 16px; height: 16px; flex: 0 0 16px; transform: translateX(-0.5px); color: currentColor; }
@@ -16,14 +16,14 @@ import { createHoverController } from "./hover-controller";
16
16
  import { createDetailContent } from "./details-content";
17
17
  import { deriveConnectionStatus, type Task, type View, type ViewResponse, type Theme } from "./model";
18
18
  import { defaultTabForPhase, derivePhase, type DisplayPhase } from "./phase";
19
- import { DETAILS_PANEL_BREAKPOINT, detailsPanelFits, isDetailInteraction, resolveDetailSurface, selectedObjectFocusSelector } from "./interaction-policy";
19
+ import { DETAILS_PANEL_BREAKPOINT, detailsPanelFits, resolveDetailSurface, selectedObjectFocusSelector } from "./interaction-policy";
20
20
  import { applyDetailSurface, installDetailViewport } from "./detail-surface";
21
21
  import { firstVisibleElement, focusAfterRerender } from "./focus";
22
22
  import { installViewStateNavigation } from "./navigation";
23
23
  import { createHoverPreviewBridge, type HoverPreviewBridge } from "./hover-preview";
24
24
  import { formatDuration, formatLocalTime } from "./time";
25
25
  import { renderTimestamp, refreshTimestamps } from "./timestamp";
26
- import { planStateSeverity, renderPlanInfoContent, renderPlanState } from "./status-row";
26
+ import { planStateSeverity, renderPlanInfoContent } from "./status-row";
27
27
  import { taskKindLabel, STATE_LABELS } from "./labels";
28
28
  import { DEFAULT_VIEW_STATE, defaultTaskLayout, hasValidLayoutParam, canonicalViewUrl, hasValidTabParam, parseViewState, replaceViewState, sanitizeViewState, type DashboardTab, type TaskLayout, type ViewState } from "./view-state";
29
29
  const ICONS = { Contrast, Network, Search, X, Plus, Minus, Maximize2, ChevronDown, CircleDashed, CircleDot, CirclePlay, LoaderCircle, Hourglass, CircleAlert, CircleHelp, CircleCheck, GitMerge, Columns3, LayoutGrid, MessageCircle, GitPullRequest, TreePalm, ArchiveX, ArchiveRestore, Archive, RefreshCw, Info, Settings2, SunMoon, Sun, Moon };
@@ -222,13 +222,6 @@ function refreshCardDurations(): void {
222
222
  function updateTaskLayoutControls(): void {
223
223
  for (const [id, layout] of [["task-layout-board", "board"], ["task-layout-grid", "grid"]] as const) $<HTMLButtonElement>(id).setAttribute("aria-pressed", String(viewState.layout === layout));
224
224
  $("group-section").hidden = viewState.layout === "board";
225
- const heading = $(viewState.layout === "board" ? "task-heading" : "group-heading").parentElement!;
226
- const actions = document.querySelector<HTMLElement>(".section-heading-actions")!;
227
- if (actions.parentElement !== heading) {
228
- const focused = actions.contains(document.activeElement) ? document.activeElement as HTMLElement : null;
229
- heading.append(actions);
230
- focused?.focus({ preventScroll: true });
231
- }
232
225
  }
233
226
  function syncStableGridCardWidth(): void {
234
227
  const workspace = document.querySelector<HTMLElement>(".workspace");
@@ -306,6 +299,30 @@ function canUseDetailsPanel(): boolean {
306
299
  const workspace = document.querySelector<HTMLElement>(".workspace");
307
300
  return detailsPanelFits(workspace?.getBoundingClientRect().width ?? 0);
308
301
  }
302
+ const DETAILS_MIN_HEIGHT = 120;
303
+ const detailsHeightKey = `loomhelm:details-height:${location.pathname}`;
304
+ function detailsHeightBounds(): { min: number; max: number } {
305
+ const workspace = document.querySelector<HTMLElement>(".workspace");
306
+ const available = Math.max(DETAILS_MIN_HEIGHT, workspace?.getBoundingClientRect().height ?? window.innerHeight);
307
+ return { min: DETAILS_MIN_HEIGHT, max: Math.max(DETAILS_MIN_HEIGHT, Math.min(window.innerHeight * 0.6, available - 240)) };
308
+ }
309
+ function setDetailsHeight(value: number, persist = true): void {
310
+ const bounds = detailsHeightBounds(); const height = Math.round(Math.max(bounds.min, Math.min(bounds.max, value)));
311
+ document.documentElement.style.setProperty("--details-height", `${height}px`);
312
+ const separator = document.getElementById("details-resize");
313
+ separator?.setAttribute("aria-valuemin", String(bounds.min)); separator?.setAttribute("aria-valuemax", String(bounds.max)); separator?.setAttribute("aria-valuenow", String(height));
314
+ if (persist) localStorage.setItem(detailsHeightKey, String(height));
315
+ }
316
+ const savedDetailsHeight = Number(localStorage.getItem(detailsHeightKey));
317
+ setDetailsHeight(Number.isFinite(savedDetailsHeight) && savedDetailsHeight > 0 ? savedDetailsHeight : window.innerHeight * 0.4, false);
318
+ const detailsResize = document.getElementById("details-resize");
319
+ if (detailsResize) {
320
+ let startY = 0; let startHeight = 0;
321
+ const move = (event: PointerEvent) => { event.preventDefault(); setDetailsHeight(startHeight + startY - event.clientY); };
322
+ const end = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); };
323
+ detailsResize.addEventListener("pointerdown", event => { event.preventDefault(); startY = event.clientY; startHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--details-height")) || window.innerHeight * 0.4; detailsResize.setPointerCapture?.(event.pointerId); window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", end); });
324
+ detailsResize.addEventListener("keydown", event => { const current = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--details-height")) || window.innerHeight * 0.4; if (event.key === "ArrowUp") { event.preventDefault(); setDetailsHeight(current + 24); } else if (event.key === "ArrowDown") { event.preventDefault(); setDetailsHeight(current - 24); } else if (event.key === "Home") { event.preventDefault(); setDetailsHeight(detailsHeightBounds().min); } else if (event.key === "End") { event.preventDefault(); setDetailsHeight(detailsHeightBounds().max); } });
325
+ }
309
326
  const hoverController = createHoverController({
310
327
  element: $,
311
328
  getData: () => data,
@@ -324,7 +341,8 @@ const hoverController = createHoverController({
324
341
  const { supportsHoverDetails, showHoverPreview, hideHoverPreview, recordPointerType, showCurrentSelectionPreview, dismiss: dismissHoverPreview, pointerTypeFromEvent } = hoverController;
325
342
  function updateDetailsSurface(open: boolean, focus = false): ReturnType<typeof resolveDetailSurface> {
326
343
  const detailPanelViewport = canUseDetailsPanel();
327
- const drawerViewport = window.innerWidth < DETAILS_PANEL_BREAKPOINT;
344
+ const mediumShort = window.innerWidth >= DETAILS_PANEL_BREAKPOINT && window.innerWidth <= 1444 && window.innerHeight <= 960;
345
+ const drawerViewport = window.innerWidth < DETAILS_PANEL_BREAKPOINT || mediumShort;
328
346
  const surface = resolveDetailSurface({ selected: hasSelectedObject(), detailOpen: open, hoverCapable: supportsHoverDetails(), keyboardSelection, detailPanelViewport, drawerViewport });
329
347
  applyDetailSurface({ workspace: document.querySelector<HTMLElement>(".workspace")!, background: [document.querySelector<HTMLElement>(".topbar")!, document.querySelector<HTMLElement>(".overview")!, document.querySelector<HTMLElement>(".workspace-main")!], details: $("details"), detailsBackdrop: $("details-backdrop"), hoverPreview: $("hover-preview"), closeDetails: $("close-details") }, surface, detailPanelViewport, focus);
330
348
  return surface;
@@ -561,7 +579,6 @@ function render(): void {
561
579
  const planWarning = $("plan-warning");
562
580
  const planState = data.plan_state;
563
581
  renderPlanInfo();
564
- renderPlanState($("plan-state"), planState);
565
582
  const authorizationLabel = planState?.authorization_label ??
566
583
  (planState?.approval_pending ? "Not approved" : "Schedulable");
567
584
  const runtimeBlockers = planState?.runtime_readiness?.status === "blocked"
@@ -635,7 +652,9 @@ async function refresh(): Promise<void> {
635
652
  }
636
653
  if (serial !== requestSerial || result.revision < revision) return;
637
654
  const previousPhase = phase; revision = result.revision; activePlanId = result.active_plan_id; data = result.view; applyOutputs({ output_epoch: result.output_epoch ?? "static-demo", output_revision: result.output_revision ?? 0, outputs: Object.fromEntries([...data.tasks.map(t => [t.id, t.latest_agent_output ?? initialOutput(t.thread_id)]), ...data.groups.map(g => [`group:${g.id}`, g.latest_agent_output ?? initialOutput(g.agent_thread_id)])]) }, outputEpoch); const currentPhase = derivePhase(data.tasks, data.execution_phase);
638
- if (!hydrated && !userChangedTab && !hasValidTabParam(window.location.search)) viewState = { ...viewState, tab: defaultTabForPhase(currentPhase) };
655
+ const explicitUrlState = parseViewState(window.location.search);
656
+ if (hasValidTabParam(window.location.search)) viewState = { ...viewState, tab: explicitUrlState.tab };
657
+ else if (!hydrated && !userChangedTab) viewState = { ...viewState, tab: defaultTabForPhase(currentPhase) };
639
658
  if (!hasValidLayoutParam(window.location.search)) viewState = { ...viewState, layout: defaultTaskLayout(data.groups.length) };
640
659
  const requestedTask = viewState.selected?.kind === "task" ? viewState.selected.id : missingLinkedTask;
641
660
  const missingTask = requestedTask && !data.tasks.some(task => task.id === requestedTask);
@@ -666,7 +685,7 @@ $("version-menu").addEventListener("keydown", (event) => { if (event.key !== "En
666
685
  $("task-layout-board").addEventListener("click", () => setTaskLayout("board")); $("task-layout-grid").addEventListener("click", () => setTaskLayout("grid"));
667
686
  $("plan-info-trigger").addEventListener("click", (event) => { event.stopPropagation(); if (planInfoIsOpen()) closePlanInfo(false); else openPlanInfo(); });
668
687
  $("close-plan-info").addEventListener("click", () => closePlanInfo()); $("plan-info-backdrop").addEventListener("click", () => closePlanInfo()); $("plan-info-panel").addEventListener("click", (event) => event.stopPropagation());
669
- document.addEventListener("click", (event) => { const target = event.target as HTMLElement; if (!target.closest("[data-menu]")) closeMenus(); if (planInfoIsOpen() && !target.closest("#plan-info-panel, #plan-info-trigger")) closePlanInfo(); if (viewState.detail && $("details").classList.contains("open") && !isDetailInteraction(target)) closeDetails(); });
688
+ document.addEventListener("click", (event) => { const target = event.target as HTMLElement; if (!target.closest("[data-menu]")) closeMenus(); if (planInfoIsOpen() && !target.closest("#plan-info-panel, #plan-info-trigger")) closePlanInfo(); });
670
689
  document.addEventListener("pointerdown", (event) => { keyboardSelection = false; recordPointerType(event.pointerType); }, true); document.addEventListener("pointermove", (event) => recordPointerType(event.pointerType), true);
671
690
  document.addEventListener("keydown", (event) => { if ((event.key === "Enter" || event.key === " ") && (event.target as HTMLElement).closest(".task-card-select, .task-column-select, .task-row")) keyboardSelection = true; }, true);
672
691
  document.addEventListener("keydown", (event) => { const target = event.target as HTMLElement; const trigger = target.closest<HTMLButtonElement>(".menu-trigger, .theme-trigger"); const option = target.closest<HTMLButtonElement>("[role=option]"); if (event.key === "Escape" && trigger) { event.preventDefault(); event.stopPropagation(); closeMenus(); trigger.focus(); } else if (trigger && (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ")) { event.preventDefault(); const id = trigger.id.replace(/-trigger$/, "") as MenuId; openMenu(id); } else if (option && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); const options = [...option.closest<HTMLElement>("[role=listbox]")!.querySelectorAll<HTMLButtonElement>("[role=option]")]; const index = options.indexOf(option); options[(index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length]?.focus(); } else if (option && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); const menu = option.closest<HTMLElement>("[role=listbox]"); const id = menu?.id.replace(/-menu$/, "") as MenuId; chooseMenu(id, option.dataset.menuValue ?? ""); } else if (option && event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeMenus(); menuTrigger(option.closest<HTMLElement>("[role=listbox]")?.id.replace(/-menu$/, "") as MenuId).focus(); } });
@@ -675,7 +694,7 @@ $("zoom-in").addEventListener("click", () => (viewState.tab === "atomic" ? atomi
675
694
  $("timeline-zoom-in").addEventListener("click", () => timelineController.adjustWindow("in")); $("timeline-zoom-out").addEventListener("click", () => timelineController.adjustWindow("out")); $("timeline-fit").addEventListener("click", () => timelineController.fit());
676
695
  installViewStateNavigation(window, { getSearch: () => window.location.search, getValidation: (search) => { const targetPlan = search ? new URLSearchParams(search).get("plan") : null; if ((targetPlan ?? activePlanId) !== (viewState.plan ?? activePlanId)) return { plans: new Set(data?.plans.map((plan) => plan.id) ?? []) }; return data ? stateValidation() : {}; }, getPhase: () => data ? phase ?? derivePhase(data.tasks) : null, restore: (next, hasExplicitTab) => { const planChanged = next.plan !== viewState.plan; const closingDetails = viewState.detail && !next.detail; userChangedTab = hasExplicitTab; if (closingDetails) { keyboardSelection = false; hideHoverPreview(); restoreClosedDetailsOnNextRender = true; } viewState = next; selectedTask = next.selected?.kind === "task" ? next.selected.id : null; selectedGroup = next.selected?.kind === "group" ? next.selected.id : null; selectedMilestone = next.selected?.kind === "milestone" ? next.selected.id : null; activePlan = next.plan ?? ""; if (next.detail) focusDetailsOnNextRender = true; if (planChanged) void refresh(); else if (data) render(); } });
677
696
  window.addEventListener("keydown", (event) => { trapDetailsFocus(event); trapPlanInfoFocus(event); if (event.key === "Escape" && planInfoIsOpen()) { event.preventDefault(); closePlanInfo(); } else if (event.key === "Escape" && viewState.detail) closeDetails(); });
678
- window.addEventListener("resize", () => { refreshResponsiveDetails(); syncPlanInfoSurface(); if (data && viewState.tab === "graph" && graphController.rankDir() !== (window.innerWidth < 768 ? "TB" : "LR")) graphController.render(filteredTasks(), viewState.group ? new Set([viewState.group]) : undefined); window.requestAnimationFrame(() => timelineController.redraw()); });
697
+ window.addEventListener("resize", () => { setDetailsHeight(Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--details-height")) || window.innerHeight * 0.4, false); refreshResponsiveDetails(); syncPlanInfoSurface(); if (data && viewState.tab === "graph" && graphController.rankDir() !== (window.innerWidth < 768 ? "TB" : "LR")) graphController.render(filteredTasks(), viewState.group ? new Set([viewState.group]) : undefined); window.requestAnimationFrame(() => timelineController.redraw()); });
679
698
  anyHoverCapability.addEventListener("change", refreshResponsiveDetails); anyFinePointerCapability.addEventListener("change", refreshResponsiveDetails);
680
699
  const stream = new EventSource("api/events"); stream.addEventListener("open", () => { connected = true; connection("live"); void refresh(); }); stream.addEventListener("revision", () => void refresh()); stream.addEventListener("stale", () => { connected = false; connection("stale"); }); stream.addEventListener("heartbeat", (event) => { try { if (JSON.parse((event as MessageEvent).data).watching && !connected) { connected = true; void refresh(); } } catch { connection("stale"); } }); stream.addEventListener("error", () => { connected = false; connection("stale"); });
681
700
  hoverBridge = createHoverPreviewBridge($("hover-preview"), dismissHoverPreview);
@@ -149,7 +149,7 @@ export function createAtomicGraphController(context: GraphContext) {
149
149
  pending = false;
150
150
  container.style.height = `${Math.max(420, result.height + 80)}px`;
151
151
  update?.();
152
- requestAnimationFrame(() => { if (serial !== generation) return; instance?.setViewport({ x: Math.max(24, (container.clientWidth - result.width) / 2), y: 24, zoom: 1 }); context.setZoom(1); highlightAccessibleSelection(projection, context); });
152
+ requestAnimationFrame(() => { if (serial !== generation) return; instance?.setViewport({ x: Math.max(24, (container.clientWidth - result.width) / 2), y: Math.max(24, (container.clientHeight - result.height) / 2), zoom: 1 }); context.setZoom(1); highlightAccessibleSelection(projection, context); });
153
153
  }).catch(() => { if (serial !== generation) return; pending = false; update?.(); });
154
154
  }
155
155