@dsdenes/devboard 0.1.559 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { connection } from "./connection.svelte";
|
|
2
|
+
import { notifications } from "./notifications.svelte";
|
|
3
|
+
import type {
|
|
4
|
+
GoalDraftDetail,
|
|
5
|
+
GoalDraftRun,
|
|
6
|
+
GoalDraftSummary,
|
|
7
|
+
IssuesActivityEntry,
|
|
8
|
+
IssuesAgent,
|
|
9
|
+
IssuesRunDetail,
|
|
10
|
+
IssuesRunSummary,
|
|
11
|
+
IssuesSnapshot,
|
|
12
|
+
QueuedIssue,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
export const GOAL_LABEL = "goal";
|
|
16
|
+
export const DRAFT_PATH_KEY = "devboard.issues.draft.v1";
|
|
17
|
+
export const DRAFT_PAGE_KEY = "devboard.issues.draftsPage.v1";
|
|
18
|
+
|
|
19
|
+
export function markDraftsPage(open: boolean): void {
|
|
20
|
+
try {
|
|
21
|
+
if (open) localStorage.setItem(DRAFT_PAGE_KEY, "1");
|
|
22
|
+
else localStorage.removeItem(DRAFT_PAGE_KEY);
|
|
23
|
+
} catch {
|
|
24
|
+
// Storage may be unavailable; the current session still works.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function draftsPageWasOpen(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
return localStorage.getItem(DRAFT_PAGE_KEY) === "1";
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isGoalIssue(issue: { labels?: string[] }): boolean {
|
|
37
|
+
return issue.labels?.includes(GOAL_LABEL) === true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readDraftPath(): string | null {
|
|
45
|
+
try {
|
|
46
|
+
const value = localStorage.getItem(DRAFT_PATH_KEY);
|
|
47
|
+
return value === null || value.length === 0 ? null : value;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDraftPath(path: string | null): void {
|
|
54
|
+
try {
|
|
55
|
+
if (path === null) localStorage.removeItem(DRAFT_PATH_KEY);
|
|
56
|
+
else localStorage.setItem(DRAFT_PATH_KEY, path);
|
|
57
|
+
} catch {
|
|
58
|
+
// Storage may be unavailable; the selection then lasts the session only.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSnapshot(value: Record<string, unknown>): boolean {
|
|
63
|
+
return (
|
|
64
|
+
value.type === "issuesState" &&
|
|
65
|
+
typeof value.root === "string" &&
|
|
66
|
+
typeof value.lifecycle === "string" &&
|
|
67
|
+
typeof value.enabled === "boolean" &&
|
|
68
|
+
Array.isArray(value.queue) &&
|
|
69
|
+
Array.isArray(value.runs)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isEntry(value: unknown): value is IssuesActivityEntry {
|
|
74
|
+
return isObject(value) && typeof value.text === "string" && typeof value.append === "boolean";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyActivity(lines: string[], entries: IssuesActivityEntry[], replay: boolean): string[] {
|
|
78
|
+
const next = replay ? [] : [...lines];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.append && next.length > 0) next[next.length - 1] += entry.text;
|
|
81
|
+
else next.push(entry.text);
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The Issues mode's client state. It never polls: the dashboard replays the
|
|
88
|
+
* worker's snapshot and activity on connect, then streams both as the worker
|
|
89
|
+
* persists them. Controls only send commands.
|
|
90
|
+
*/
|
|
91
|
+
export class IssuesStore {
|
|
92
|
+
snapshot = $state<IssuesSnapshot | null>(null);
|
|
93
|
+
agentActivity = $state<Record<string, string[]>>({});
|
|
94
|
+
run = $state<IssuesRunDetail | null>(null);
|
|
95
|
+
runError = $state<string | null>(null);
|
|
96
|
+
selectedRun = $state<string | null>(null);
|
|
97
|
+
private requestedRun: string | null = null;
|
|
98
|
+
/** Issue numbers whose close request is still unanswered. */
|
|
99
|
+
closing = $state<number[]>([]);
|
|
100
|
+
|
|
101
|
+
drafts = $state<GoalDraftSummary[]>([]);
|
|
102
|
+
draftRun = $state<GoalDraftRun | null>(null);
|
|
103
|
+
draft = $state<GoalDraftDetail | null>(null);
|
|
104
|
+
draftPath = $state<string | null>(readDraftPath());
|
|
105
|
+
draftError = $state<string | null>(null);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True once the connect-time replay has named the live agents. A run request
|
|
109
|
+
* made before the replay cannot tell a finished run from a running one.
|
|
110
|
+
*/
|
|
111
|
+
get ready(): boolean {
|
|
112
|
+
return this.snapshot !== null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get lifecycle(): string {
|
|
116
|
+
return this.snapshot?.lifecycle ?? "stopped";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get repo(): string | null {
|
|
120
|
+
return this.snapshot?.repo ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The header's origin label. A missing snapshot is not evidence that the
|
|
125
|
+
* checkout has no origin, so only a received snapshot may say so.
|
|
126
|
+
*/
|
|
127
|
+
get repoLabel(): string {
|
|
128
|
+
if (this.repo !== null) return this.repo;
|
|
129
|
+
return this.snapshot === null ? "…" : "No GitHub origin";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get enabled(): boolean {
|
|
133
|
+
return this.snapshot?.enabled === true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get paused(): boolean {
|
|
137
|
+
return this.snapshot?.paused === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get running(): boolean {
|
|
141
|
+
return this.lifecycle === "running" || this.lifecycle === "pausing";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get queue(): QueuedIssue[] {
|
|
145
|
+
return [...(this.snapshot?.queue ?? [])].sort((left, right) => left.number - right.number);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get runs(): IssuesRunSummary[] {
|
|
149
|
+
return this.snapshot?.runs ?? [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get agents(): IssuesAgent[] {
|
|
153
|
+
return [...(this.snapshot?.agents ?? [])].sort(
|
|
154
|
+
(left, right) => left.issueNumber - right.issueNumber,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
activityFor(runId: string): string[] {
|
|
159
|
+
return this.agentActivity[runId] ?? [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
agentFor(runId: string): IssuesAgent | null {
|
|
163
|
+
return this.snapshot?.agents.find((agent) => agent.runId === runId) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The run status a card shows: the live run's status, else its history. */
|
|
167
|
+
statusFor(runId: string): string {
|
|
168
|
+
return this.snapshot?.runs.find((run) => run.runId === runId)?.status ?? "running";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get notice(): string | null {
|
|
172
|
+
return this.snapshot?.notice ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get pending(): number {
|
|
176
|
+
return this.queue.filter((issue) => !issue.handled).length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
get cooldowns(): Record<string, string> {
|
|
180
|
+
return this.snapshot?.cooldowns ?? {};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get cooldownCount(): number {
|
|
184
|
+
return Object.keys(this.cooldowns).length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get earliestCooldown(): string | null {
|
|
188
|
+
const times = Object.values(this.cooldowns)
|
|
189
|
+
.map((value) => Date.parse(value))
|
|
190
|
+
.filter((value) => Number.isFinite(value));
|
|
191
|
+
if (times.length === 0) return null;
|
|
192
|
+
return new Date(Math.min(...times)).toISOString();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get draftRunning(): boolean {
|
|
196
|
+
return this.draftRun?.status === "running";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get draftBusyPath(): string | null {
|
|
200
|
+
return this.draftRunning ? (this.draftRun?.path ?? null) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get draftStatus(): string {
|
|
204
|
+
return this.draftRun?.status ?? "idle";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The stated reason another draft's start control is disabled, or null. */
|
|
208
|
+
draftStartBlocker(path: string): string | null {
|
|
209
|
+
const busy = this.draftBusyPath;
|
|
210
|
+
if (busy === null) return null;
|
|
211
|
+
return busy === path
|
|
212
|
+
? "This draft is being elaborated."
|
|
213
|
+
: `An elaboration is running for ${busy}.`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
refreshDrafts(): void {
|
|
217
|
+
if (!connection.open) return;
|
|
218
|
+
connection.send({ type: "getGoalDrafts" });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
openDraft(path: string): void {
|
|
222
|
+
this.draftPath = path;
|
|
223
|
+
this.draft = null;
|
|
224
|
+
this.draftError = null;
|
|
225
|
+
writeDraftPath(path);
|
|
226
|
+
if (connection.open) connection.send({ type: "getGoalDraft", path });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
clearDraft(): void {
|
|
230
|
+
this.draftPath = null;
|
|
231
|
+
this.draft = null;
|
|
232
|
+
this.draftError = null;
|
|
233
|
+
writeDraftPath(null);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
startDraft(): void {
|
|
237
|
+
const path = this.draftPath;
|
|
238
|
+
if (path === null || this.draftRunning) return;
|
|
239
|
+
this.draftError = null;
|
|
240
|
+
connection.send({ type: "startGoalDraft", path });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
stopDraft(): void {
|
|
244
|
+
if (!this.draftRunning) return;
|
|
245
|
+
connection.send({ type: "stopGoalDraft" });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
submitDraftAnswer(value: string): void {
|
|
249
|
+
const question = this.draftRun?.question ?? null;
|
|
250
|
+
if (question === null || value.trim().length === 0) return;
|
|
251
|
+
connection.send({ type: "answerGoalDraft", id: question.id, value: value.trim() });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
get canStart(): boolean {
|
|
255
|
+
return connection.open && !this.enabled;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
get canPause(): boolean {
|
|
259
|
+
return this.enabled && !this.paused && this.lifecycle !== "pausing";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
get canResume(): boolean {
|
|
263
|
+
return this.enabled && this.paused && this.lifecycle !== "stopped";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
get canStop(): boolean {
|
|
267
|
+
return this.enabled;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
get canCheck(): boolean {
|
|
271
|
+
return this.enabled && !this.paused;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
start(): void {
|
|
275
|
+
connection.send({ type: "startIssues" });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
pause(): void {
|
|
279
|
+
connection.send({ type: "pauseIssues" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
resume(): void {
|
|
283
|
+
connection.send({ type: "resumeIssues" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
stop(): void {
|
|
287
|
+
connection.send({ type: "stopIssues" });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
check(): void {
|
|
291
|
+
connection.send({ type: "checkIssues" });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
canClose(number: number): boolean {
|
|
295
|
+
return connection.open && !this.closing.includes(number);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Closes one issue on the tracker as not planned. */
|
|
299
|
+
closeIssue(number: number): void {
|
|
300
|
+
if (!Number.isInteger(number) || number <= 0) return;
|
|
301
|
+
if (!this.canClose(number)) return;
|
|
302
|
+
this.closing = [...this.closing, number];
|
|
303
|
+
if (!connection.send({ type: "closeIssue", number })) {
|
|
304
|
+
this.closing = this.closing.filter((entry) => entry !== number);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
submitAgentAnswer(runId: string, questionId: number | string, value: string): void {
|
|
309
|
+
if (value.trim().length === 0) return;
|
|
310
|
+
connection.send({
|
|
311
|
+
type: "answerIssues",
|
|
312
|
+
id: questionId,
|
|
313
|
+
value: value.trim(),
|
|
314
|
+
runId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Requests one run's history. The selection and the requested marker move
|
|
320
|
+
* only after the frame reaches the socket, so a rejected send leaves loadRun
|
|
321
|
+
* free to retry instead of claiming a request that never happened.
|
|
322
|
+
*/
|
|
323
|
+
openRun(runId: string): void {
|
|
324
|
+
if (!connection.send({ type: "getIssuesRun", runId })) return;
|
|
325
|
+
this.selectedRun = runId;
|
|
326
|
+
this.requestedRun = runId;
|
|
327
|
+
this.run = null;
|
|
328
|
+
this.runError = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Fetches one run's history once unless it is already selected. */
|
|
332
|
+
loadRun(runId: string): void {
|
|
333
|
+
if (this.requestedRun === runId && (this.run?.runId === runId || this.selectedRun === runId)) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.openRun(runId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
closeRun(): void {
|
|
340
|
+
this.selectedRun = null;
|
|
341
|
+
this.requestedRun = null;
|
|
342
|
+
this.run = null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
handleEvent(message: Record<string, unknown>): void {
|
|
346
|
+
if (isSnapshot(message)) {
|
|
347
|
+
const next = message as unknown as IssuesSnapshot;
|
|
348
|
+
this.snapshot = next;
|
|
349
|
+
const live = new Set((next.agents ?? []).map((agent) => agent.runId));
|
|
350
|
+
const kept: Record<string, string[]> = {};
|
|
351
|
+
for (const [runId, lines] of Object.entries(this.agentActivity)) {
|
|
352
|
+
if (live.has(runId)) kept[runId] = lines;
|
|
353
|
+
}
|
|
354
|
+
this.agentActivity = kept;
|
|
355
|
+
const open = new Set((next.queue ?? []).map((issue) => issue.number));
|
|
356
|
+
this.closing = this.closing.filter((number) => open.has(number));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (message.type === "issuesActivity" && typeof message.runId === "string") {
|
|
360
|
+
const runId = message.runId;
|
|
361
|
+
const entries = Array.isArray(message.entries) ? message.entries.filter(isEntry) : [];
|
|
362
|
+
if (!(this.snapshot?.agents ?? []).some((agent) => agent.runId === runId)) return;
|
|
363
|
+
this.agentActivity = {
|
|
364
|
+
...this.agentActivity,
|
|
365
|
+
[runId]: applyActivity(this.agentActivity[runId] ?? [], entries, message.replay === true),
|
|
366
|
+
};
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (message.type === "issuesRun" && isObject(message.run)) {
|
|
370
|
+
const run = message.run as unknown as IssuesRunDetail;
|
|
371
|
+
if (typeof run.runId === "string" && Array.isArray(run.activityLines)) {
|
|
372
|
+
this.run = run;
|
|
373
|
+
this.runError = null;
|
|
374
|
+
this.selectedRun = run.runId;
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (message.type === "issuesClosed" && typeof message.number === "number") {
|
|
379
|
+
const number = message.number;
|
|
380
|
+
// A successful closure stays pending until the queue no longer lists the
|
|
381
|
+
// issue, so a paused or stopped worker cannot invite a duplicate call.
|
|
382
|
+
if (message.ok === true) {
|
|
383
|
+
notifications.push("success", `Closed issue #${number}.`);
|
|
384
|
+
} else {
|
|
385
|
+
this.closing = this.closing.filter((entry) => entry !== number);
|
|
386
|
+
if (typeof message.message === "string") notifications.push("error", message.message);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (message.type === "error" && message.message === "No such issues run.") {
|
|
391
|
+
this.runError = "No such issues run.";
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (message.type === "issuesDrafts" && Array.isArray(message.drafts)) {
|
|
395
|
+
const drafts = (message.drafts as GoalDraftSummary[]).filter(
|
|
396
|
+
(draft) => typeof draft?.path === "string" && typeof draft?.name === "string",
|
|
397
|
+
);
|
|
398
|
+
this.drafts = drafts.sort((left, right) =>
|
|
399
|
+
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
|
400
|
+
);
|
|
401
|
+
const known = new Set(drafts.map((draft) => draft.path));
|
|
402
|
+
if (this.draftPath !== null && !known.has(this.draftPath)) {
|
|
403
|
+
this.clearDraft();
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (message.type === "issuesDraftState" && isObject(message.draft)) {
|
|
408
|
+
const run = message.draft as unknown as GoalDraftRun;
|
|
409
|
+
if (typeof run.status !== "string") return;
|
|
410
|
+
this.draftRun = {
|
|
411
|
+
...run,
|
|
412
|
+
activity: Array.isArray(run.activity) ? run.activity : [],
|
|
413
|
+
question: (run.question ?? null) as GoalDraftRun["question"],
|
|
414
|
+
};
|
|
415
|
+
if (run.status === "running") this.draftError = null;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (message.type === "issuesDraftDetail" && isObject(message.draft)) {
|
|
419
|
+
const draft = message.draft as unknown as GoalDraftDetail;
|
|
420
|
+
if (typeof draft.path === "string" && typeof draft.text === "string") {
|
|
421
|
+
this.draft = draft;
|
|
422
|
+
this.draftPath = draft.path;
|
|
423
|
+
writeDraftPath(draft.path);
|
|
424
|
+
this.draftError = null;
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (message.type === "issuesDraftError" && typeof message.message === "string") {
|
|
429
|
+
// A stored path whose draft is gone opens the list instead of an error.
|
|
430
|
+
if (this.draft === null && this.draftPath !== null && message.message.startsWith("No draft at")) {
|
|
431
|
+
this.clearDraft();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.draftError = message.message;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export const issues = new IssuesStore();
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { Mode, Theme } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface WorkspaceTab {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
resourceId: string;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface LayoutSnapshot {
|
|
11
|
+
version: 1;
|
|
12
|
+
mode: Mode;
|
|
13
|
+
theme: Theme;
|
|
14
|
+
tabs: Record<Mode, WorkspaceTab[]>;
|
|
15
|
+
activeTab: Record<Mode, string | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const LAYOUT_KEY = "devboard.layout.v1";
|
|
19
|
+
export const THEME_KEY = "devboard.theme";
|
|
20
|
+
export const PANE_KEY_PREFIX = "paneforge:";
|
|
21
|
+
|
|
22
|
+
const MODES: Mode[] = ["goal", "issues", "flow", "discover"];
|
|
23
|
+
|
|
24
|
+
function defaultTab(mode: Mode): WorkspaceTab {
|
|
25
|
+
return {
|
|
26
|
+
id: "overview",
|
|
27
|
+
kind: "overview",
|
|
28
|
+
resourceId: "overview",
|
|
29
|
+
title: mode === "goal" ? "Implementation" : "Overview",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultTabs(): Record<Mode, WorkspaceTab[]> {
|
|
34
|
+
return {
|
|
35
|
+
goal: [defaultTab("goal")],
|
|
36
|
+
issues: [defaultTab("issues")],
|
|
37
|
+
flow: [defaultTab("flow")],
|
|
38
|
+
discover: [defaultTab("discover")],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultActive(): Record<Mode, string | null> {
|
|
43
|
+
return { goal: "overview", issues: "overview", flow: "overview", discover: "overview" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isMode(value: unknown): value is Mode {
|
|
47
|
+
return value === "goal" || value === "issues" || value === "flow" || value === "discover";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validTab(value: unknown): value is WorkspaceTab {
|
|
51
|
+
if (typeof value !== "object" || value === null) return false;
|
|
52
|
+
const tab = value as Record<string, unknown>;
|
|
53
|
+
return (
|
|
54
|
+
typeof tab.id === "string" &&
|
|
55
|
+
typeof tab.kind === "string" &&
|
|
56
|
+
typeof tab.resourceId === "string" &&
|
|
57
|
+
typeof tab.title === "string"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A tab a previous revision could persist but this one no longer renders. */
|
|
62
|
+
function normalizeTab(mode: Mode, tab: WorkspaceTab): WorkspaceTab {
|
|
63
|
+
if (mode === "flow" && tab.id === "flow-goal") return defaultTab("flow");
|
|
64
|
+
if (mode === "issues" && tab.kind === "issues") {
|
|
65
|
+
return { ...tab, kind: "queue", id: "issues" };
|
|
66
|
+
}
|
|
67
|
+
if (mode === "discover" && tab.kind === "term") {
|
|
68
|
+
return { id: "glossary", kind: "glossary", resourceId: "glossary", title: "Glossary" };
|
|
69
|
+
}
|
|
70
|
+
if (mode === "goal" && tab.id === "overview") return { ...tab, title: "Implementation" };
|
|
71
|
+
return tab;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSnapshot(): LayoutSnapshot | null {
|
|
75
|
+
try {
|
|
76
|
+
const raw = localStorage.getItem(LAYOUT_KEY);
|
|
77
|
+
if (raw === null) return null;
|
|
78
|
+
const parsed: unknown = JSON.parse(raw);
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
80
|
+
const snapshot = parsed as Record<string, unknown>;
|
|
81
|
+
if (!isMode(snapshot.mode)) return null;
|
|
82
|
+
const tabs = snapshot.tabs as Record<string, unknown> | undefined;
|
|
83
|
+
const active = snapshot.activeTab as Record<string, unknown> | undefined;
|
|
84
|
+
if (tabs === undefined || active === undefined) return null;
|
|
85
|
+
const nextTabs = defaultTabs();
|
|
86
|
+
const nextActive = defaultActive();
|
|
87
|
+
for (const mode of MODES) {
|
|
88
|
+
const list = tabs[mode];
|
|
89
|
+
if (Array.isArray(list)) {
|
|
90
|
+
const clean = list.filter(validTab);
|
|
91
|
+
const id = active[mode];
|
|
92
|
+
const chosen = clean.find((tab) => tab.id === id) ?? clean[0];
|
|
93
|
+
if (chosen !== undefined) {
|
|
94
|
+
const restored = normalizeTab(mode, chosen);
|
|
95
|
+
nextTabs[mode] = [restored];
|
|
96
|
+
nextActive[mode] = restored.id;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const theme: Theme = snapshot.theme === "light" ? "light" : "dark";
|
|
101
|
+
return {
|
|
102
|
+
version: 1,
|
|
103
|
+
mode: snapshot.mode,
|
|
104
|
+
theme,
|
|
105
|
+
tabs: nextTabs,
|
|
106
|
+
activeTab: nextActive,
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class LayoutStore {
|
|
114
|
+
mode = $state<Mode>("goal");
|
|
115
|
+
theme = $state<Theme>("dark");
|
|
116
|
+
tabs = $state<Record<Mode, WorkspaceTab[]>>(defaultTabs());
|
|
117
|
+
activeTab = $state<Record<Mode, string | null>>(defaultActive());
|
|
118
|
+
/** Bumping the epoch remounts PaneForge groups so stored split sizes are gone. */
|
|
119
|
+
epoch = $state(0);
|
|
120
|
+
|
|
121
|
+
constructor() {
|
|
122
|
+
const snapshot = readSnapshot();
|
|
123
|
+
if (snapshot !== null) {
|
|
124
|
+
this.mode = snapshot.mode;
|
|
125
|
+
this.theme = snapshot.theme;
|
|
126
|
+
this.tabs = snapshot.tabs;
|
|
127
|
+
this.activeTab = snapshot.activeTab;
|
|
128
|
+
} else {
|
|
129
|
+
try {
|
|
130
|
+
const stored = localStorage.getItem(THEME_KEY);
|
|
131
|
+
if (stored === "light" || stored === "dark") this.theme = stored;
|
|
132
|
+
} catch {
|
|
133
|
+
// A blocked localStorage leaves the dark default.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get activeTabId(): string | null {
|
|
139
|
+
return this.activeTab[this.mode];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get activeWorkspaceTab(): WorkspaceTab | null {
|
|
143
|
+
const id = this.activeTab[this.mode];
|
|
144
|
+
return this.tabs[this.mode].find((tab) => tab.id === id) ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
tab(mode: Mode, id: string): WorkspaceTab | null {
|
|
148
|
+
return this.tabs[mode].find((tab) => tab.id === id) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setMode(mode: Mode): void {
|
|
152
|
+
this.mode = mode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setTheme(theme: Theme): void {
|
|
156
|
+
this.theme = theme;
|
|
157
|
+
applyTheme(theme);
|
|
158
|
+
try {
|
|
159
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
160
|
+
} catch {
|
|
161
|
+
// The theme still applies for this session.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
toggleTheme(): void {
|
|
166
|
+
this.setTheme(this.theme === "dark" ? "light" : "dark");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One surface per mode: opening a tab replaces the visible page. */
|
|
170
|
+
openTab(mode: Mode, tab: WorkspaceTab, activate = true): void {
|
|
171
|
+
this.tabs[mode] = [tab];
|
|
172
|
+
if (activate) this.activeTab[mode] = tab.id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
activateTab(mode: Mode, id: string): void {
|
|
176
|
+
if (!this.tabs[mode].some((tab) => tab.id === id)) return;
|
|
177
|
+
this.activeTab[mode] = id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Removes a tab and returns the new active id so callers can move focus. */
|
|
181
|
+
closeTab(mode: Mode, id: string): string | null {
|
|
182
|
+
const list = this.tabs[mode];
|
|
183
|
+
const index = list.findIndex((tab) => tab.id === id);
|
|
184
|
+
if (index < 0) return this.activeTab[mode];
|
|
185
|
+
const remaining = list.filter((tab) => tab.id !== id);
|
|
186
|
+
this.tabs[mode] = remaining;
|
|
187
|
+
if (this.activeTab[mode] === id) {
|
|
188
|
+
const next = remaining[Math.min(index, remaining.length - 1)] ?? null;
|
|
189
|
+
this.activeTab[mode] = next?.id ?? null;
|
|
190
|
+
}
|
|
191
|
+
return this.activeTab[mode];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
reset(): void {
|
|
195
|
+
try {
|
|
196
|
+
for (const key of Object.keys(localStorage)) {
|
|
197
|
+
if (key.startsWith(PANE_KEY_PREFIX)) localStorage.removeItem(key);
|
|
198
|
+
}
|
|
199
|
+
localStorage.removeItem(LAYOUT_KEY);
|
|
200
|
+
} catch {
|
|
201
|
+
// Reset still resets the in-memory layout.
|
|
202
|
+
}
|
|
203
|
+
this.mode = "goal";
|
|
204
|
+
this.tabs = defaultTabs();
|
|
205
|
+
this.activeTab = defaultActive();
|
|
206
|
+
this.setTheme("dark");
|
|
207
|
+
this.epoch += 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
serialize(): LayoutSnapshot {
|
|
211
|
+
return {
|
|
212
|
+
version: 1,
|
|
213
|
+
mode: this.mode,
|
|
214
|
+
theme: this.theme,
|
|
215
|
+
tabs: this.tabs,
|
|
216
|
+
activeTab: this.activeTab,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
persist(): void {
|
|
221
|
+
try {
|
|
222
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify(this.serialize()));
|
|
223
|
+
} catch {
|
|
224
|
+
// Layout persistence is best effort; the session keeps working.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function applyTheme(theme: Theme): void {
|
|
230
|
+
document.documentElement.dataset.theme = theme;
|
|
231
|
+
document.documentElement.style.colorScheme = theme;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const layout = new LayoutStore();
|