diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000000..2b58e54751
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,13 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git push:*)",
+ "Bash(gh auth:*)",
+ "Bash(ssh:*)",
+ "Bash(echo:*)",
+ "Bash(git remote set-url:*)",
+ "Bash(git checkout:*)",
+ "Bash(git pull:*)"
+ ]
+ }
+}
diff --git a/README.md b/README.md
index 115f673cb9..f4a4b2a1a0 100644
--- a/README.md
+++ b/README.md
@@ -46,9 +46,11 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local
## Forge
- Canonical Forge module docs: [docs/forge.md](docs/forge.md).
-- Installs and uninstalls bundled Forge templates into workspace-scoped `.agent` artifacts.
-- Returns a normalized template plan prompt for plan mode (including compatibility rewrites and `@plan` enforcement).
-- Lists workspace plans and joins task status from matching `state-v2` files.
+- Forge panel remains available from the sidebar header even when no project is selected.
+- No-project mode is explicit and blocking: Forge shows `Select a project first to use Forge.` and disables template, plan, execution, and clean-progress actions.
+- During execution, Forge renders exactly one running task row from live `runningInfo`; non-active stale `in_progress` rows are demoted to `completed` (all phases complete) or `pending` (otherwise).
+- Execution is plan-scoped: Forge validates each backend `forge_get_next_phase_prompt` task id against the selected plan's visible task ids before `startThread`; unexpected tasks stop with a visible error and no extra session/thread is created.
+- With a selected project, Forge installs/uninstalls bundled templates into workspace-scoped `.agent` artifacts, returns normalized plan prompts, and lists workspace plans with `state-v2` task status joins.
## Requirements
diff --git a/coverage/base.css b/coverage/base.css
new file mode 100644
index 0000000000..f418035b46
--- /dev/null
+++ b/coverage/base.css
@@ -0,0 +1,224 @@
+body, html {
+ margin:0; padding: 0;
+ height: 100%;
+}
+body {
+ font-family: Helvetica Neue, Helvetica, Arial;
+ font-size: 14px;
+ color:#333;
+}
+.small { font-size: 12px; }
+*, *:after, *:before {
+ -webkit-box-sizing:border-box;
+ -moz-box-sizing:border-box;
+ box-sizing:border-box;
+ }
+h1 { font-size: 20px; margin: 0;}
+h2 { font-size: 14px; }
+pre {
+ font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ margin: 0;
+ padding: 0;
+ -moz-tab-size: 2;
+ -o-tab-size: 2;
+ tab-size: 2;
+}
+a { color:#0074D9; text-decoration:none; }
+a:hover { text-decoration:underline; }
+.strong { font-weight: bold; }
+.space-top1 { padding: 10px 0 0 0; }
+.pad2y { padding: 20px 0; }
+.pad1y { padding: 10px 0; }
+.pad2x { padding: 0 20px; }
+.pad2 { padding: 20px; }
+.pad1 { padding: 10px; }
+.space-left2 { padding-left:55px; }
+.space-right2 { padding-right:20px; }
+.center { text-align:center; }
+.clearfix { display:block; }
+.clearfix:after {
+ content:'';
+ display:block;
+ height:0;
+ clear:both;
+ visibility:hidden;
+ }
+.fl { float: left; }
+@media only screen and (max-width:640px) {
+ .col3 { width:100%; max-width:100%; }
+ .hide-mobile { display:none!important; }
+}
+
+.quiet {
+ color: #7f7f7f;
+ color: rgba(0,0,0,0.5);
+}
+.quiet a { opacity: 0.7; }
+
+.fraction {
+ font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+ font-size: 10px;
+ color: #555;
+ background: #E8E8E8;
+ padding: 4px 5px;
+ border-radius: 3px;
+ vertical-align: middle;
+}
+
+div.path a:link, div.path a:visited { color: #333; }
+table.coverage {
+ border-collapse: collapse;
+ margin: 10px 0 0 0;
+ padding: 0;
+}
+
+table.coverage td {
+ margin: 0;
+ padding: 0;
+ vertical-align: top;
+}
+table.coverage td.line-count {
+ text-align: right;
+ padding: 0 5px 0 20px;
+}
+table.coverage td.line-coverage {
+ text-align: right;
+ padding-right: 10px;
+ min-width:20px;
+}
+
+table.coverage td span.cline-any {
+ display: inline-block;
+ padding: 0 5px;
+ width: 100%;
+}
+.missing-if-branch {
+ display: inline-block;
+ margin-right: 5px;
+ border-radius: 3px;
+ position: relative;
+ padding: 0 4px;
+ background: #333;
+ color: yellow;
+}
+
+.skip-if-branch {
+ display: none;
+ margin-right: 10px;
+ position: relative;
+ padding: 0 4px;
+ background: #ccc;
+ color: white;
+}
+.missing-if-branch .typ, .skip-if-branch .typ {
+ color: inherit !important;
+}
+.coverage-summary {
+ border-collapse: collapse;
+ width: 100%;
+}
+.coverage-summary tr { border-bottom: 1px solid #bbb; }
+.keyline-all { border: 1px solid #ddd; }
+.coverage-summary td, .coverage-summary th { padding: 10px; }
+.coverage-summary tbody { border: 1px solid #bbb; }
+.coverage-summary td { border-right: 1px solid #bbb; }
+.coverage-summary td:last-child { border-right: none; }
+.coverage-summary th {
+ text-align: left;
+ font-weight: normal;
+ white-space: nowrap;
+}
+.coverage-summary th.file { border-right: none !important; }
+.coverage-summary th.pct { }
+.coverage-summary th.pic,
+.coverage-summary th.abs,
+.coverage-summary td.pct,
+.coverage-summary td.abs { text-align: right; }
+.coverage-summary td.file { white-space: nowrap; }
+.coverage-summary td.pic { min-width: 120px !important; }
+.coverage-summary tfoot td { }
+
+.coverage-summary .sorter {
+ height: 10px;
+ width: 7px;
+ display: inline-block;
+ margin-left: 0.5em;
+ background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
+}
+.coverage-summary .sorted .sorter {
+ background-position: 0 -20px;
+}
+.coverage-summary .sorted-desc .sorter {
+ background-position: 0 -10px;
+}
+.status-line { height: 10px; }
+/* yellow */
+.cbranch-no { background: yellow !important; color: #111; }
+/* dark red */
+.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
+.low .chart { border:1px solid #C21F39 }
+.highlighted,
+.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
+ background: #C21F39 !important;
+}
+/* medium red */
+.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
+/* light red */
+.low, .cline-no { background:#FCE1E5 }
+/* light green */
+.high, .cline-yes { background:rgb(230,245,208) }
+/* medium green */
+.cstat-yes { background:rgb(161,215,106) }
+/* dark green */
+.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
+.high .chart { border:1px solid rgb(77,146,33) }
+/* dark yellow (gold) */
+.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
+.medium .chart { border:1px solid #f9cd0b; }
+/* light yellow */
+.medium { background: #fff4c2; }
+
+.cstat-skip { background: #ddd; color: #111; }
+.fstat-skip { background: #ddd; color: #111 !important; }
+.cbranch-skip { background: #ddd !important; color: #111; }
+
+span.cline-neutral { background: #eaeaea; }
+
+.coverage-summary td.empty {
+ opacity: .5;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ line-height: 1;
+ color: #888;
+}
+
+.cover-fill, .cover-empty {
+ display:inline-block;
+ height: 12px;
+}
+.chart {
+ line-height: 0;
+}
+.cover-empty {
+ background: white;
+}
+.cover-full {
+ border-right: none !important;
+}
+pre.prettyprint {
+ border: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+.com { color: #999 !important; }
+.ignore-none { color: #999; font-weight: normal; }
+
+.wrapper {
+ min-height: 100%;
+ height: auto !important;
+ height: 100%;
+ margin: 0 auto -48px;
+}
+.footer, .push {
+ height: 48px;
+}
diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js
new file mode 100644
index 0000000000..530d1ed2ba
--- /dev/null
+++ b/coverage/block-navigation.js
@@ -0,0 +1,87 @@
+/* eslint-disable */
+var jumpToCode = (function init() {
+ // Classes of code we would like to highlight in the file view
+ var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
+
+ // Elements to highlight in the file listing view
+ var fileListingElements = ['td.pct.low'];
+
+ // We don't want to select elements that are direct descendants of another match
+ var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
+
+ // Selector that finds elements on the page to which we can jump
+ var selector =
+ fileListingElements.join(', ') +
+ ', ' +
+ notSelector +
+ missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
+
+ // The NodeList of matching elements
+ var missingCoverageElements = document.querySelectorAll(selector);
+
+ var currentIndex;
+
+ function toggleClass(index) {
+ missingCoverageElements
+ .item(currentIndex)
+ .classList.remove('highlighted');
+ missingCoverageElements.item(index).classList.add('highlighted');
+ }
+
+ function makeCurrent(index) {
+ toggleClass(index);
+ currentIndex = index;
+ missingCoverageElements.item(index).scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'center'
+ });
+ }
+
+ function goToPrevious() {
+ var nextIndex = 0;
+ if (typeof currentIndex !== 'number' || currentIndex === 0) {
+ nextIndex = missingCoverageElements.length - 1;
+ } else if (missingCoverageElements.length > 1) {
+ nextIndex = currentIndex - 1;
+ }
+
+ makeCurrent(nextIndex);
+ }
+
+ function goToNext() {
+ var nextIndex = 0;
+
+ if (
+ typeof currentIndex === 'number' &&
+ currentIndex < missingCoverageElements.length - 1
+ ) {
+ nextIndex = currentIndex + 1;
+ }
+
+ makeCurrent(nextIndex);
+ }
+
+ return function jump(event) {
+ if (
+ document.getElementById('fileSearch') === document.activeElement &&
+ document.activeElement != null
+ ) {
+ // if we're currently focused on the search input, we don't want to navigate
+ return;
+ }
+
+ switch (event.which) {
+ case 78: // n
+ case 74: // j
+ goToNext();
+ break;
+ case 66: // b
+ case 75: // k
+ case 80: // p
+ goToPrevious();
+ break;
+ }
+ };
+})();
+window.addEventListener('keydown', jumpToCode);
diff --git a/coverage/clover.xml b/coverage/clover.xml
new file mode 100644
index 0000000000..fb5f41b6c9
--- /dev/null
+++ b/coverage/clover.xml
@@ -0,0 +1,1198 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 | 1x +1x +1x +1x +1x +1x +1x +1x +1x + +1x +1x + + + +1x +1x + + + + + + + + + + + + + + +1x + + + + + + + + + + + + +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x +1x +1x + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + +1x +1x +1x +1x + + + + +40x +40x +40x +40x +40x +40x +40x + + + + +40x +40x + + +40x +40x + +75x +75x +75x +1x +1x +75x +15x +15x +59x +59x + +344x +344x +344x +344x +344x +344x +344x +344x +14x +14x +344x +59x +59x +344x +105x +105x +166x +166x + +110x +110x +110x +110x +110x +110x +110x +170x +170x +110x +110x + + + + + +110x + +330x +330x +330x +330x +330x +330x +224x +224x +330x +4x +4x +330x +59x +59x +43x +43x + +349x +349x +349x +349x +349x +349x +349x + + + + + +349x +349x +19x +19x + +330x +349x + +349x +4x + +4x +4x + +326x +326x + +4x +4x +4x +4x +4x +4x +4x +4x + + + + + + +4x +4x +4x +1x + + +1x +1x +1x +4x +4x +4x + +4x +3x +1x +1x +1x +1x +1x +4x +3x +1x + +4x +4x +4x +4x +4x + +4x +4x + +4x +4x +4x +4x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x + + + +3x +3x +3x +3x +3x +3x +1x +1x +1x +3x +3x + +3x +3x +3x + +1x + + +4x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +1x +1x +1x +5x + +5x +5x +5x +5x + +4x +4x +4x +4x + +4x +4x +4x + +4x + +1x +323x +323x +323x +323x +323x +323x +323x +323x +323x + + + + + + + + + + + + + + + + +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x +323x +89x +89x + + +89x +89x +89x +89x +88x +88x +88x +88x + +89x +323x +323x + +323x +323x +45x +43x +43x +45x +45x + + +45x +45x + + + + +2x +2x +45x +45x +45x +2x +2x +2x +45x +323x +323x + +323x +323x +323x +323x +323x + +323x +44x +44x +44x +44x + + +44x +44x +44x + +44x +44x +44x +44x +323x + +323x +44x +44x +3x +3x +3x +3x +3x +3x +41x +41x +41x + + +41x +41x +41x + +41x +41x +41x +41x +323x + +323x +323x +194x + + +194x +194x +194x +194x +194x + +194x +194x +194x +194x +323x +323x + +323x +44x +44x +3x +3x +3x +3x +3x + +41x +193x + + +193x +193x + +41x +41x +41x +41x +41x +41x +323x + +323x +90x +40x +75x +75x +75x +75x +75x +75x +40x + +40x +40x +40x +40x +40x +90x +323x + +323x +323x +1x + + +1x +1x +1x +1x +1x + +1x +1x +323x +323x + +323x +1x + + +1x +1x +1x +1x +1x + +1x +323x + +323x +323x +180x +143x +323x + +323x +323x + +323x + +323x +323x +323x +323x +323x + +323x +44x +41x +41x +3x +323x + +323x +127x +88x +88x +127x + + +323x + +323x +85x +85x +48x +48x +48x +48x +48x + +85x +85x +188x +188x +188x +188x +188x +188x +188x + + +188x +188x + + + + + + +188x + +85x +85x +85x +37x +37x +37x +323x +323x +323x +323x +323x +323x + +323x +44x +44x +44x +44x +43x +44x +323x +323x +323x +323x +323x + +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x + +81x +323x + +323x +1x + + + +1x + + + +1x +1x +1x +1x +1x + +1x +1x + +1x +1x +1x + + +1x +1x + + + + +1x + +1x +1x + + + + +1x +1x +1x +1x +1x + + + + +1x + + +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x +1x +1x +1x + + + +1x +1x +1x +1x + + + +1x +1x +1x + + +1x +1x +1x + +1x +1x +1x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x +323x +323x +323x +323x + +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x +323x +323x +323x +323x +323x +323x +39x + + +39x +39x +323x +323x +323x + +323x +323x +323x +323x +323x + +323x +42x +42x +38x +38x +38x +38x +38x +38x +37x +37x +37x +38x + +38x +38x +42x +42x +42x +42x + + + +42x +42x + +42x +42x +42x +42x +42x + +42x +42x + +323x +323x + +323x +323x +323x +323x +323x +323x +180x +19x +161x +143x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +9x +314x +134x +180x +53x +127x + +323x +51x + + +51x +17x +17x +17x +34x +51x + +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +9x +314x +134x +180x + +323x +1x +1x + +323x +323x +323x +323x +323x +323x +12x +12x +12x +311x +323x +9x + +9x +314x +180x +180x +349x +349x +349x +349x +349x +349x +349x +349x +349x +349x +349x +349x + +349x +349x +349x +349x +349x +6x +343x +104x + +239x + +349x +349x +349x +19x +19x +19x +330x +349x +349x +120x +120x +344x +344x +344x +344x +344x +344x +344x +344x +344x +344x +344x +344x + +344x +344x +344x +344x +344x + +120x +120x +229x +349x + +180x +180x + +134x + +134x + +323x +323x + +323x +4x +4x +4x +4x +4x +4x +4x +4x + +323x + +323x + | import CheckSquare2 from "lucide-react/dist/esm/icons/check-square-2";
+import ChevronDown from "lucide-react/dist/esm/icons/chevron-down";
+import Circle from "lucide-react/dist/esm/icons/circle";
+import Pencil from "lucide-react/dist/esm/icons/pencil";
+import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw";
+import LayoutTemplate from "lucide-react/dist/esm/icons/layout-template";
+import Pause from "lucide-react/dist/esm/icons/pause";
+import Play from "lucide-react/dist/esm/icons/play";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { CollaborationModeOption, WorkspaceInfo } from "../../../types";
+import { ModalShell } from "../../design-system/components/modal/ModalShell";
+import {
+ PopoverMenuItem,
+ PopoverSurface,
+} from "../../design-system/components/popover/PopoverPrimitives";
+import { useDismissibleMenu } from "../../app/hooks/useDismissibleMenu";
+import {
+ forgeGetInstalledTemplate,
+ forgeInstallTemplate,
+ forgeLoadPhaseView,
+ forgeListBundledTemplates,
+ forgeUninstallTemplate,
+ type ForgeBundledTemplateInfo,
+ type ForgeNextPhasePrompt,
+ type ForgePhaseViewStatus,
+ type ForgePhaseView,
+ type ForgePhaseStatus,
+ type ForgeRunPhaseChecksResponse,
+ type ForgeTemplateLock,
+ type ForgeWorkspacePlan,
+} from "../../../services/tauri";
+import {
+ connectWorkspace,
+ forgeResetExecutionProgress,
+ forgeGetNextPhasePrompt,
+ forgeGetPlanPrompt,
+ forgeGetPhaseStatus,
+ forgeListPlans,
+ forgePrepareExecution,
+ forgeRunPhaseChecks,
+ interruptTurn,
+ sendUserMessage,
+ startThread,
+} from "../../../services/tauri";
+import { useForgeExecution } from "../hooks/useForgeExecution";
+import { getForgePhaseIconUrl } from "../../../utils/forgePhaseIcons";
+
+export type ForgeTemplatesClient = {
+ listBundledTemplates: () => Promise<ForgeBundledTemplateInfo[]>;
+ getInstalledTemplate: (workspaceId: string) => Promise<ForgeTemplateLock | null>;
+ installTemplate: (workspaceId: string, templateId: string) => Promise<ForgeTemplateLock>;
+ uninstallTemplate: (workspaceId: string) => Promise<void>;
+};
+
+export type ForgePlansClient = {
+ listPlans: (workspaceId: string) => Promise<ForgeWorkspacePlan[]>;
+ loadPhaseView?: (
+ workspaceId: string,
+ planId: string,
+ installedTemplateId: string | null,
+ ) => Promise<ForgePhaseView>;
+ getPlanPrompt: (workspaceId: string) => Promise<string>;
+ prepareExecution: (workspaceId: string, planId: string) => Promise<void>;
+ resetExecutionProgress: (workspaceId: string, planId: string) => Promise<void>;
+ getNextPhasePrompt: (
+ workspaceId: string,
+ planId: string,
+ ) => Promise<ForgeNextPhasePrompt | null>;
+ getPhaseStatus: (
+ workspaceId: string,
+ planId: string,
+ taskId: string,
+ phaseId: string,
+ ) => Promise<ForgePhaseStatus>;
+ runPhaseChecks: (
+ workspaceId: string,
+ planId: string,
+ taskId: string,
+ phaseId: string,
+ ) => Promise<ForgeRunPhaseChecksResponse>;
+ interruptTurn: (
+ workspaceId: string,
+ threadId: string,
+ turnId: string,
+ ) => Promise<unknown>;
+ connectWorkspace: (workspaceId: string) => Promise<void>;
+ startThread: (workspaceId: string) => Promise<any>;
+ sendUserMessage: (
+ workspaceId: string,
+ threadId: string,
+ text: string,
+ options?: {
+ collaborationMode?: Record<string, unknown> | null;
+ },
+ ) => Promise<any>;
+};
+
+const defaultForgeTemplatesClient: ForgeTemplatesClient = {
+ listBundledTemplates: forgeListBundledTemplates,
+ getInstalledTemplate: forgeGetInstalledTemplate,
+ installTemplate: forgeInstallTemplate,
+ uninstallTemplate: forgeUninstallTemplate,
+};
+
+const defaultForgePlansClient: ForgePlansClient = {
+ listPlans: forgeListPlans,
+ loadPhaseView: forgeLoadPhaseView,
+ getPlanPrompt: forgeGetPlanPrompt,
+ prepareExecution: forgePrepareExecution,
+ resetExecutionProgress: forgeResetExecutionProgress,
+ getNextPhasePrompt: forgeGetNextPhasePrompt,
+ getPhaseStatus: forgeGetPhaseStatus,
+ runPhaseChecks: forgeRunPhaseChecks,
+ interruptTurn,
+ connectWorkspace,
+ startThread,
+ sendUserMessage,
+};
+
+type ForgeItemStatus = "pending" | "inProgress" | "completed";
+
+type ForgePlanItem = {
+ id: string;
+ title: string;
+ status: ForgeItemStatus;
+};
+
+type ForgePlan = {
+ id: string;
+ name: string;
+ items: ForgePlanItem[];
+};
+
+const EMPTY_PHASE_VIEW: ForgePhaseView = {
+ phases: [],
+ taskPhaseStatusByTaskId: {},
+};
+
+type ForgePhaseChipState = "is-complete" | "is-current" | "is-pending";
+type ForgeRunningInfo = { taskId: string; phaseId: string } | null;
+
+function formatPlanLabel(plan: ForgeWorkspacePlan): string {
+ const title = plan.title?.trim() ?? "";
+ const goal = plan.goal?.trim() ?? "";
+ const id = plan.id?.trim() ?? "";
+ if (title && id) {
+ return `${title} (${id})`;
+ }
+ if (title) {
+ return title;
+ }
+ const shortGoal =
+ goal.length > 60 ? `${goal.slice(0, 57).trimEnd()}...` : goal;
+ if (shortGoal && id) {
+ return `${shortGoal} (${id})`;
+ }
+ return shortGoal || id || "Untitled plan";
+}
+
+function mapForgeItemStatus(status: string): ForgeItemStatus {
+ const normalized = status.trim().toLowerCase();
+ if (normalized === "completed") {
+ return "completed";
+ }
+ if (normalized === "in_progress") {
+ return "inProgress";
+ }
+ return "pending";
+}
+
+function mapForgePhaseChipState(
+ taskId: string,
+ phaseId: string,
+ phaseStatus: ForgePhaseViewStatus,
+ runningInfo: ForgeRunningInfo,
+): ForgePhaseChipState {
+ const normalizedStatus = phaseStatus.trim().toLowerCase();
+ if (runningInfo?.taskId === taskId && runningInfo.phaseId === phaseId) {
+ return "is-current";
+ }
+ if (normalizedStatus === "in_progress") {
+ return "is-current";
+ }
+ if (normalizedStatus === "completed") {
+ return "is-complete";
+ }
+ return "is-pending";
+}
+
+function allTaskPhasesCompleted(
+ taskPhaseStatuses: Record<string, ForgePhaseViewStatus>,
+ phases: ForgePhaseView["phases"],
+): boolean {
+ if (phases.length > 0) {
+ return phases.every(
+ (phase) =>
+ (taskPhaseStatuses[phase.id] ?? "pending").trim().toLowerCase() ===
+ "completed",
+ );
+ }
+ const phaseStatuses = Object.values(taskPhaseStatuses);
+ return (
+ phaseStatuses.length > 0 &&
+ phaseStatuses.every((status) => status.trim().toLowerCase() === "completed")
+ );
+}
+
+function deriveStatusFromTaskPhases(
+ taskPhaseStatuses: Record<string, ForgePhaseViewStatus>,
+ phases: ForgePhaseView["phases"],
+): ForgeItemStatus | null {
+ const phaseStatuses = Object.values(taskPhaseStatuses);
+ if (phaseStatuses.length === 0) {
+ return null;
+ }
+ if (allTaskPhasesCompleted(taskPhaseStatuses, phases)) {
+ return "completed";
+ }
+ if (phaseStatuses.some((status) => status.trim().toLowerCase() === "in_progress")) {
+ return "inProgress";
+ }
+ return "pending";
+}
+
+function resolveForgeTaskRowStatus({
+ taskId,
+ planTaskStatus,
+ taskPhaseStatuses,
+ phases,
+ runningInfo,
+}: {
+ taskId: string;
+ planTaskStatus: ForgeItemStatus;
+ taskPhaseStatuses: Record<string, ForgePhaseViewStatus>;
+ phases: ForgePhaseView["phases"];
+ runningInfo: ForgeRunningInfo;
+}): ForgeItemStatus {
+ if (runningInfo?.taskId === taskId) {
+ return "inProgress";
+ }
+
+ const phaseDerivedStatus = deriveStatusFromTaskPhases(taskPhaseStatuses, phases);
+ const resolvedStatus = phaseDerivedStatus ?? planTaskStatus;
+
+ if (runningInfo && resolvedStatus === "inProgress") {
+ return allTaskPhasesCompleted(taskPhaseStatuses, phases)
+ ? "completed"
+ : "pending";
+ }
+
+ return resolvedStatus;
+}
+
+function ForgeTemplatesModal({
+ templates,
+ installedTemplate,
+ canManage,
+ onInstallTemplate,
+ onUninstallTemplate,
+ onClose,
+}: {
+ templates: ForgeBundledTemplateInfo[];
+ installedTemplate: ForgeTemplateLock | null;
+ canManage: boolean;
+ onInstallTemplate: (templateId: string) => void;
+ onUninstallTemplate: () => void;
+ onClose: () => void;
+}) {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") {
+ return;
+ }
+ event.preventDefault();
+ onClose();
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ const installed = installedTemplate
+ ? templates.find((t) => t.id === installedTemplate.installedTemplateId) ?? {
+ id: installedTemplate.installedTemplateId,
+ title: installedTemplate.installedTemplateId,
+ version: installedTemplate.installedTemplateVersion,
+ }
+ : null;
+ const others = installed
+ ? templates.filter((t) => t.id !== installed.id)
+ : templates;
+
+ return (
+ <ModalShell
+ className="forge-templates-modal"
+ onBackdropClick={onClose}
+ ariaLabel="Forge templates"
+ >
+ <div className="ds-modal-title forge-templates-title">Templates</div>
+ <div className="ds-modal-subtitle forge-templates-subtitle">
+ Choose a template to install or swap. Behaviors are stubbed for now.
+ </div>
+ <div className="ds-modal-divider" />
+ <div className="forge-templates-list" role="list">
+ {installed ? (
+ <div className="forge-template-row" role="listitem">
+ <div className="forge-template-main">
+ <div className="forge-template-name">{installed.title}</div>
+ <div className="forge-template-meta">
+ Installed{installed.version ? ` (${installed.version})` : ""}
+ </div>
+ </div>
+ <div className="forge-template-actions">
+ <button
+ type="button"
+ className="ghost forge-template-icon"
+ aria-label="Edit template"
+ title="Edit template"
+ onClick={() => {
+ // TODO(Forge): wire template edit
+ }}
+ >
+ <Pencil aria-hidden />
+ </button>
+ <button
+ type="button"
+ className="ghost forge-template-action"
+ onClick={() => {
+ onUninstallTemplate();
+ onClose();
+ }}
+ disabled={!canManage}
+ >
+ Remove
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="forge-template-empty">No template installed.</div>
+ )}
+
+ {others.map((template) => {
+ const actionLabel = installed ? "Swap" : "Install";
+ return (
+ <div key={template.id} className="forge-template-row" role="listitem">
+ <div className="forge-template-main">
+ <div className="forge-template-name">{template.title}</div>
+ </div>
+ <div className="forge-template-actions">
+ <button
+ type="button"
+ className="ghost forge-template-action"
+ onClick={() => {
+ onInstallTemplate(template.id);
+ onClose();
+ }}
+ disabled={!canManage}
+ >
+ {actionLabel}
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ <div className="ds-modal-actions forge-templates-actions">
+ <button type="button" className="ghost ds-modal-button" onClick={onClose}>
+ Close
+ </button>
+ </div>
+ </ModalShell>
+ );
+}
+
+export function Forge({
+ activeWorkspaceId,
+ activeWorkspace = null,
+ sendUserMessageToThread,
+ templatesClient,
+ plansClient,
+ onSelectThread,
+ collaborationModes = [],
+ onSelectCollaborationMode,
+}: {
+ activeWorkspaceId: string | null;
+ activeWorkspace?: WorkspaceInfo | null;
+ sendUserMessageToThread?: (
+ workspace: WorkspaceInfo,
+ threadId: string,
+ message: string,
+ imageIds: string[],
+ options?: {
+ collaborationMode?: Record<string, unknown> | null;
+ },
+ ) => Promise<void>;
+ templatesClient?: ForgeTemplatesClient;
+ plansClient?: ForgePlansClient;
+ onSelectThread?: (workspaceId: string, threadId: string) => void;
+ collaborationModes?: CollaborationModeOption[];
+ onSelectCollaborationMode?: (id: string | null) => void;
+}) {
+ const client = templatesClient ?? defaultForgeTemplatesClient;
+ const plansApi = plansClient ?? defaultForgePlansClient;
+ const listBundledTemplates = client.listBundledTemplates;
+ const getInstalledTemplate = client.getInstalledTemplate;
+ const installTemplate = client.installTemplate;
+ const uninstallTemplate = client.uninstallTemplate;
+ const listPlans = plansApi.listPlans;
+ const loadPhaseView = plansApi.loadPhaseView ?? forgeLoadPhaseView;
+ const getPlanPrompt = plansApi.getPlanPrompt;
+ const prepareExecution = plansApi.prepareExecution;
+ const resetExecutionProgress = plansApi.resetExecutionProgress;
+ const getNextPhasePrompt = plansApi.getNextPhasePrompt;
+ const getPhaseStatus = plansApi.getPhaseStatus;
+ const runPhaseChecks = plansApi.runPhaseChecks;
+ const interruptPlanTurn = plansApi.interruptTurn;
+ const connectWorkspaceToRun = plansApi.connectWorkspace;
+ const startThreadForPlan = plansApi.startThread;
+ const sendUserMessageToPlanThread = plansApi.sendUserMessage;
+ const hasActiveWorkspace = Boolean(activeWorkspaceId?.trim());
+
+ const findCollaborationMode = useCallback(
+ (wanted: string) => {
+ const normalized = wanted.trim().toLowerCase();
+ if (!normalized) {
+ return null;
+ }
+ return (
+ collaborationModes.find(
+ (mode) => mode.id.trim().toLowerCase() === normalized,
+ ) ??
+ collaborationModes.find(
+ (mode) => (mode.mode || mode.id).trim().toLowerCase() === normalized,
+ ) ??
+ null
+ );
+ },
+ [collaborationModes],
+ );
+
+ const buildCollaborationModePayloadFor = useCallback(
+ (mode: CollaborationModeOption | null) => {
+ if (!mode) {
+ return null;
+ }
+ const modeValue = mode.mode || mode.id;
+ if (!modeValue) {
+ return null;
+ }
+ const modelValue = (mode.model ?? "").trim();
+ if (!modelValue) {
+ // CollaborationMode.settings.model is required by the app-server protocol.
+ // If we can't supply a model, don't send an override payload.
+ return null;
+ }
+ const settings: Record<string, unknown> = {
+ developer_instructions: mode.developerInstructions ?? null,
+ model: modelValue,
+ };
+ if (mode.reasoningEffort !== null) {
+ settings.reasoning_effort = mode.reasoningEffort;
+ }
+ return { mode: modeValue, settings };
+ },
+ [],
+ );
+
+ const [templates, setTemplates] = useState<ForgeBundledTemplateInfo[]>([]);
+ const [installedTemplate, setInstalledTemplate] = useState<ForgeTemplateLock | null>(null);
+ const [workspacePlans, setWorkspacePlans] = useState<ForgeWorkspacePlan[]>([]);
+ const [isResettingProgress, setIsResettingProgress] = useState(false);
+ const plansInFlightRef = useRef(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ void listBundledTemplates()
+ .then((items) => {
+ if (cancelled) {
+ return;
+ }
+ setTemplates(items);
+ })
+ .catch((error) => {
+ console.warn("Failed to load Forge templates.", { error });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [listBundledTemplates]);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!activeWorkspaceId) {
+ setInstalledTemplate(null);
+ setWorkspacePlans([]);
+ return () => {
+ cancelled = true;
+ };
+ }
+ void getInstalledTemplate(activeWorkspaceId)
+ .then((installed) => {
+ if (cancelled) {
+ return;
+ }
+ setInstalledTemplate(installed);
+ })
+ .catch((error) => {
+ console.warn("Failed to load installed Forge template.", { error });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeWorkspaceId, getInstalledTemplate]);
+
+ const refreshPlans = useCallback(
+ async (workspaceId: string) => {
+ if (plansInFlightRef.current) {
+ return;
+ }
+ plansInFlightRef.current = true;
+ try {
+ const next = await listPlans(workspaceId);
+ setWorkspacePlans(next);
+ } catch (error) {
+ console.warn("Failed to load Forge plans.", { error });
+ } finally {
+ plansInFlightRef.current = false;
+ }
+ },
+ [listPlans],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!activeWorkspaceId) {
+ setWorkspacePlans([]);
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ const loadPlans = async () => {
+ if (cancelled) {
+ return;
+ }
+ await refreshPlans(activeWorkspaceId);
+ };
+
+ void loadPlans();
+ const interval = window.setInterval(loadPlans, 2000);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [activeWorkspaceId, refreshPlans]);
+
+ const plans: ForgePlan[] = useMemo(() => {
+ return workspacePlans.map((plan) => {
+ const items: ForgePlanItem[] = plan.tasks.map((task) => {
+ const status = mapForgeItemStatus(task.status);
+ return {
+ id: task.id,
+ title: task.name,
+ status,
+ };
+ });
+
+ return {
+ id: plan.id,
+ name: formatPlanLabel(plan),
+ items,
+ };
+ });
+ }, [workspacePlans]);
+
+ const handleInstallTemplate = useCallback(
+ (templateId: string) => {
+ if (!activeWorkspaceId) {
+ return;
+ }
+ void installTemplate(activeWorkspaceId, templateId)
+ .then((lock) => {
+ setInstalledTemplate(lock);
+ })
+ .catch((error) => {
+ console.warn("Failed to install Forge template.", { error });
+ });
+ },
+ [activeWorkspaceId, installTemplate],
+ );
+
+ const handleUninstallTemplate = useCallback(() => {
+ if (!activeWorkspaceId) {
+ return;
+ }
+ void uninstallTemplate(activeWorkspaceId)
+ .then(() => {
+ setInstalledTemplate(null);
+ })
+ .catch((error) => {
+ console.warn("Failed to uninstall Forge template.", { error });
+ });
+ }, [activeWorkspaceId, uninstallTemplate]);
+
+ const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
+ const selectedPlan = selectedPlanId
+ ? plans.find((p) => p.id === selectedPlanId) ?? null
+ : null;
+ const [phaseView, setPhaseView] = useState<ForgePhaseView>(EMPTY_PHASE_VIEW);
+
+ const [planMenuOpen, setPlanMenuOpen] = useState(false);
+ const planMenuRef = useRef<HTMLDivElement | null>(null);
+
+ const [templatesOpen, setTemplatesOpen] = useState(false);
+
+ useDismissibleMenu({
+ isOpen: planMenuOpen,
+ containerRef: planMenuRef,
+ onClose: () => setPlanMenuOpen(false),
+ });
+
+ useEffect(() => {
+ if (hasActiveWorkspace) {
+ return;
+ }
+ setPlanMenuOpen(false);
+ }, [hasActiveWorkspace]);
+
+ useEffect(() => {
+ if (!selectedPlanId) {
+ return;
+ }
+ if (!plans.some((plan) => plan.id === selectedPlanId)) {
+ setSelectedPlanId(null);
+ }
+ }, [plans, selectedPlanId]);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!activeWorkspaceId || !selectedPlanId) {
+ setPhaseView(EMPTY_PHASE_VIEW);
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ const installedTemplateId = installedTemplate?.installedTemplateId ?? null;
+ const refreshPhaseView = async () => {
+ try {
+ const next = await loadPhaseView(
+ activeWorkspaceId,
+ selectedPlanId,
+ installedTemplateId,
+ );
+ if (cancelled) {
+ return;
+ }
+ setPhaseView(next);
+ } catch (error) {
+ if (cancelled) {
+ return;
+ }
+ console.warn("Failed to load Forge phase view.", { error });
+ setPhaseView(EMPTY_PHASE_VIEW);
+ }
+ };
+
+ void refreshPhaseView();
+ const interval = window.setInterval(refreshPhaseView, 2000);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [
+ activeWorkspaceId,
+ installedTemplate?.installedTemplateId,
+ loadPhaseView,
+ selectedPlanId,
+ ]);
+
+ const executionMode = useMemo(() => {
+ const mode =
+ findCollaborationMode("default") ??
+ findCollaborationMode("code") ??
+ collaborationModes[0] ??
+ null;
+ return buildCollaborationModePayloadFor(mode);
+ }, [buildCollaborationModePayloadFor, collaborationModes, findCollaborationMode]);
+ const selectedPlanTaskIds = useMemo(
+ () => selectedPlan?.items.map((item) => item.id) ?? null,
+ [selectedPlan],
+ );
+
+ const {
+ isExecuting,
+ runningInfo,
+ lastError,
+ startExecution,
+ pauseExecution,
+ } = useForgeExecution({
+ workspaceId: activeWorkspaceId,
+ knownTaskIds: selectedPlanTaskIds,
+ connectWorkspace: connectWorkspaceToRun,
+ prepareExecution,
+ getNextPhasePrompt,
+ getPhaseStatus,
+ runPhaseChecks,
+ interruptTurn: interruptPlanTurn,
+ startThread: startThreadForPlan,
+ sendUserMessage: sendUserMessageToPlanThread,
+ onSelectThread,
+ collaborationMode: executionMode,
+ });
+
+ useEffect(() => {
+ // Avoid executing the wrong plan if the user switches selection mid-run.
+ void pauseExecution();
+ }, [pauseExecution, selectedPlanId]);
+
+ const handleNewPlan = useCallback(async () => {
+ if (!activeWorkspaceId) {
+ return;
+ }
+
+ setPlanMenuOpen(false);
+
+ // Start in plan collaboration mode so the UI shows the structured plan turn item.
+ // Writing plan files is handled after planning (outside plan mode).
+ const planMode = findCollaborationMode("plan");
+ if (planMode?.id) {
+ onSelectCollaborationMode?.(planMode.id);
+ }
+ const collaborationMode = buildCollaborationModePayloadFor(planMode);
+
+ try {
+ await connectWorkspaceToRun(activeWorkspaceId);
+
+ const response = await startThreadForPlan(activeWorkspaceId);
+ const thread =
+ (response?.result?.thread ?? response?.thread ?? null) as
+ | Record<string, unknown>
+ | null;
+ const threadId = String(thread?.id ?? "").trim();
+ if (!threadId) {
+ console.warn("Forge new plan: missing thread id.", { response });
+ return;
+ }
+
+ onSelectThread?.(activeWorkspaceId, threadId);
+
+ const prompt = await getPlanPrompt(activeWorkspaceId);
+ if (!prompt.trim()) {
+ console.warn("Forge new plan: plan prompt is empty.");
+ return;
+ }
+
+ if (sendUserMessageToThread && activeWorkspace?.id === activeWorkspaceId) {
+ await sendUserMessageToThread(activeWorkspace, threadId, prompt, [], {
+ collaborationMode,
+ });
+ } else {
+ await sendUserMessageToPlanThread(activeWorkspaceId, threadId, prompt, {
+ collaborationMode,
+ });
+ }
+ } catch (error) {
+ console.warn("Forge new plan: failed to start plan thread.", { error });
+ }
+ }, [
+ activeWorkspaceId,
+ activeWorkspace,
+ buildCollaborationModePayloadFor,
+ connectWorkspaceToRun,
+ findCollaborationMode,
+ getPlanPrompt,
+ onSelectCollaborationMode,
+ onSelectThread,
+ sendUserMessageToPlanThread,
+ sendUserMessageToThread,
+ startThreadForPlan,
+ ]);
+
+ const handleCleanProgress = useCallback(async () => {
+ const workspaceId = activeWorkspaceId?.trim();
+ const planId = selectedPlanId?.trim();
+ if (!workspaceId || !planId || isResettingProgress) {
+ return;
+ }
+
+ const confirmed = window.confirm(
+ "Clean progress will reset this plan's state and derived execution files. Continue?",
+ );
+ if (!confirmed) {
+ return;
+ }
+
+ setIsResettingProgress(true);
+ try {
+ if (isExecuting) {
+ await pauseExecution();
+ }
+ await resetExecutionProgress(workspaceId, planId);
+ await refreshPlans(workspaceId);
+ } catch (error) {
+ console.warn("Forge clean progress failed.", { error });
+ } finally {
+ setIsResettingProgress(false);
+ }
+ }, [
+ activeWorkspaceId,
+ isExecuting,
+ isResettingProgress,
+ pauseExecution,
+ refreshPlans,
+ resetExecutionProgress,
+ selectedPlanId,
+ ]);
+
+ return (
+ <div className="forge-panel" data-testid="forge-panel">
+ <div className="forge-header">
+ <div className="forge-title">Forge</div>
+ </div>
+
+ <section className="forge-card" aria-label="Plan selection">
+ <div className="forge-card-header">
+ <div className="forge-card-title">Plan</div>
+ </div>
+ <div className="forge-plan-select" ref={planMenuRef}>
+ <button
+ type="button"
+ className="ghost forge-template-toggle"
+ aria-label="Open templates"
+ title="Templates"
+ data-tauri-drag-region="false"
+ onClick={() => setTemplatesOpen(true)}
+ disabled={!hasActiveWorkspace}
+ >
+ <LayoutTemplate aria-hidden />
+ </button>
+ <button
+ type="button"
+ className={`ghost forge-plan-toggle${planMenuOpen ? " is-open" : ""}`}
+ data-tauri-drag-region="false"
+ onClick={() => {
+ if (!hasActiveWorkspace) {
+ return;
+ }
+ setPlanMenuOpen((open) => !open);
+ }}
+ aria-haspopup="menu"
+ aria-expanded={planMenuOpen}
+ disabled={!hasActiveWorkspace}
+ >
+ <span className={selectedPlan ? "forge-plan-name" : "forge-plan-empty"}>
+ {selectedPlan ? selectedPlan.name : "Click to select"}
+ </span>
+ <ChevronDown aria-hidden />
+ </button>
+
+ {planMenuOpen && (
+ <PopoverSurface className="forge-plan-menu" role="menu">
+ {plans.map((plan) => (
+ <PopoverMenuItem
+ key={plan.id}
+ role="menuitemradio"
+ aria-checked={selectedPlanId === plan.id}
+ active={selectedPlanId === plan.id}
+ onClick={() => {
+ setSelectedPlanId(plan.id);
+ setPlanMenuOpen(false);
+ }}
+ data-tauri-drag-region="false"
+ >
+ {plan.name}
+ </PopoverMenuItem>
+ ))}
+ <div className="forge-plan-menu-divider" aria-hidden />
+ <PopoverMenuItem
+ onClick={() => {
+ // TODO(Forge): wire file selection dialog
+ setPlanMenuOpen(false);
+ }}
+ data-tauri-drag-region="false"
+ >
+ Other...
+ </PopoverMenuItem>
+ <PopoverMenuItem
+ onClick={handleNewPlan}
+ data-tauri-drag-region="false"
+ >
+ New plan...
+ </PopoverMenuItem>
+ </PopoverSurface>
+ )}
+ </div>
+ </section>
+
+ <section className="forge-card" aria-label="Plan execution">
+ <div className="forge-card-header">
+ <div className="forge-card-header-left">
+ <div className="forge-card-title">Execution</div>
+ <div className="forge-card-subtitle">
+ {selectedPlan
+ ? runningInfo
+ ? `${selectedPlan.name} • ${runningInfo.taskId}/${runningInfo.phaseId}`
+ : selectedPlan.name
+ : "No plan selected"}
+ </div>
+ </div>
+ <div className="forge-card-header-actions">
+ <button
+ type="button"
+ className="ghost forge-execute-toggle"
+ data-tauri-drag-region="false"
+ disabled={!hasActiveWorkspace || !selectedPlan}
+ aria-label={isExecuting ? "Pause plan" : "Resume plan"}
+ aria-pressed={isExecuting}
+ title={
+ !hasActiveWorkspace
+ ? "Select a project to use Forge"
+ : !selectedPlan
+ ? "Select a plan to run"
+ : isExecuting
+ ? "Pause plan"
+ : "Resume plan"
+ }
+ onClick={() => {
+ if (!hasActiveWorkspace || !selectedPlan) {
+ return;
+ }
+ if (isExecuting) {
+ void pauseExecution();
+ return;
+ }
+ void startExecution(selectedPlan.id);
+ }}
+ >
+ {isExecuting ? <Pause aria-hidden /> : <Play aria-hidden />}
+ </button>
+ <button
+ type="button"
+ className="ghost forge-reset-progress"
+ data-tauri-drag-region="false"
+ disabled={!hasActiveWorkspace || !selectedPlan || isResettingProgress}
+ aria-label="Clean progress"
+ title={
+ !hasActiveWorkspace
+ ? "Select a project to use Forge"
+ : !selectedPlan
+ ? "Select a plan to clean progress"
+ : "Reset state and derived execution files"
+ }
+ onClick={() => {
+ void handleCleanProgress();
+ }}
+ >
+ {isResettingProgress ? "Resetting..." : "Clean progress"}
+ </button>
+ </div>
+ </div>
+ <div className="forge-execution">
+ {lastError ? (
+ <div className="forge-execution-error" role="alert">
+ {lastError}
+ </div>
+ ) : null}
+ {!hasActiveWorkspace ? (
+ <div className="forge-empty">
+ Select a project first to use Forge.
+ </div>
+ ) : selectedPlan ? (
+ <ol className="forge-items">
+ {selectedPlan.items.map((item, index) => {
+ const isActiveTask = runningInfo?.taskId === item.id;
+ const taskPhaseStatuses = phaseView.taskPhaseStatusByTaskId[item.id] ?? {};
+ const effectiveStatus = resolveForgeTaskRowStatus({
+ taskId: item.id,
+ planTaskStatus: item.status,
+ taskPhaseStatuses,
+ phases: phaseView.phases,
+ runningInfo,
+ });
+ const isCompleted = effectiveStatus === "completed";
+ const isRunning = effectiveStatus === "inProgress";
+ const isActivePhase = isActiveTask && runningInfo?.phaseId;
+
+ return (
+ <li key={`${item.id}-${item.title}-${index}`} className={`forge-item ${effectiveStatus}`}>
+ <div className="forge-item-row">
+ <span className="forge-item-icon" aria-hidden>
+ {isCompleted ? (
+ <CheckSquare2 aria-hidden />
+ ) : isRunning ? (
+ <RefreshCw className="forge-running-icon spinning" aria-hidden />
+ ) : (
+ <Circle aria-hidden />
+ )}
+ </span>
+ <span className="forge-item-title">{item.title}</span>
+ {isActivePhase ? (
+ <span className="forge-item-meta">
+ Phase {runningInfo.phaseId} in progress
+ </span>
+ ) : null}
+ </div>
+ {phaseView.phases.length > 0 ? (
+ <div className="forge-phases">
+ {phaseView.phases.map((phase) => {
+ const chipState = mapForgePhaseChipState(
+ item.id,
+ phase.id,
+ taskPhaseStatuses[phase.id] ?? "pending",
+ runningInfo,
+ );
+ const iconUrl = getForgePhaseIconUrl(phase.iconId);
+ return (
+ <span
+ key={`${item.id}-${phase.id}`}
+ className={`forge-phase ${chipState}`}
+ data-phase-id={phase.id}
+ >
+ <span className="forge-phase-icon" aria-hidden>
+ <img src={iconUrl} alt="" aria-hidden />
+ </span>
+ <span className="forge-phase-name">{phase.title}</span>
+ </span>
+ );
+ })}
+ </div>
+ ) : null}
+ </li>
+ );
+ })}
+ </ol>
+ ) : (
+ <div className="forge-empty">
+ Select a plan to see execution progress.
+ </div>
+ )}
+ </div>
+ </section>
+
+ {templatesOpen && (
+ <ForgeTemplatesModal
+ templates={templates}
+ installedTemplate={installedTemplate}
+ canManage={Boolean(activeWorkspaceId)}
+ onInstallTemplate={handleInstallTemplate}
+ onUninstallTemplate={handleUninstallTemplate}
+ onClose={() => setTemplatesOpen(false)}
+ />
+ )}
+ </div>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Forge.tsx | +
+
+ |
+ 91.08% | +756/830 | +78.57% | +176/224 | +86.95% | +20/23 | +91.08% | +756/830 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| useForgeExecution.ts | +
+
+ |
+ 80.28% | +281/350 | +73.58% | +78/106 | +88.88% | +8/9 | +80.28% | +281/350 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 | 1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + +281x +281x +281x +281x +269x +269x +269x +269x +269x +268x +268x +268x +268x + +281x + +13x +13x +13x +13x +1x +1x +1x +1x + +13x + +29x +29x + + + +29x +29x +29x +29x + + + +29x +29x +29x + +30x +30x + + + +30x +30x +30x +30x +29x +29x + +1x +30x +30x + + + + + + + + + + + + + + + + + + + +269x +269x +269x +269x +269x + +1x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + + + +323x +323x +83x +44x +44x +39x +83x +74x +74x +74x +74x +74x +39x +323x + +323x +115x +115x +323x + +323x +98x +98x +98x +98x +98x +98x +81x +81x + +17x +17x +17x +17x +98x +98x +98x + + +323x + +323x +44x +44x +44x +44x +44x +323x + +323x +323x +34x +34x +34x + + + +34x +34x +34x +34x +34x +34x +34x + +34x + +34x +30x +30x +30x +30x +281x +281x +281x +281x +281x +281x +281x + + +281x +13x +13x +268x +251x + +30x + +34x +34x +34x + + + +34x +34x + + + +34x +34x +34x +34x +34x + + +34x +46x +46x + + +46x +5x +5x +41x +46x + + +41x +46x + + + +46x +11x +11x +11x +11x + +46x +46x +46x +29x +29x +29x +29x +46x + + + + +46x +46x +29x +29x +29x +29x +29x + + + +29x +29x + + + + + + + +29x +29x +29x +29x +29x +4x +4x +4x +29x + + + + + + + +29x +46x +1x +1x +1x + +30x + +30x +46x + + +30x +30x +30x +30x +30x +30x +30x +30x +46x + + +30x +46x +1x +1x +1x +1x +1x +1x + +30x + +46x + + +13x + +46x + + + +46x +1x +1x +1x +1x + +12x +12x +12x +12x +12x +12x +46x +1x +1x +1x +46x +34x +12x +12x +12x + +12x +12x +34x +17x +17x +17x +17x +17x +17x +17x +17x +34x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x +323x + +323x +323x +168x +168x +168x +168x +168x +168x +323x +323x +323x + | import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type {
+ ForgeNextPhasePrompt,
+ ForgePhaseStatus,
+ ForgeRunPhaseChecksResponse,
+} from "../../../services/tauri";
+
+type ForgeExecutionStatusLike =
+ | "pending"
+ | "in_progress"
+ | "completed"
+ | "failed"
+ | "canceled"
+ | string;
+
+type ForgeExecutionArgs = {
+ workspaceId: string | null;
+ knownTaskIds?: string[] | null;
+ connectWorkspace: (workspaceId: string) => Promise<void>;
+ prepareExecution: (workspaceId: string, planId: string) => Promise<void>;
+ getNextPhasePrompt: (
+ workspaceId: string,
+ planId: string,
+ ) => Promise<ForgeNextPhasePrompt | null>;
+ getPhaseStatus: (
+ workspaceId: string,
+ planId: string,
+ taskId: string,
+ phaseId: string,
+ ) => Promise<ForgePhaseStatus>;
+ runPhaseChecks: (
+ workspaceId: string,
+ planId: string,
+ taskId: string,
+ phaseId: string,
+ ) => Promise<ForgeRunPhaseChecksResponse>;
+ interruptTurn: (
+ workspaceId: string,
+ threadId: string,
+ turnId: string,
+ ) => Promise<unknown>;
+ startThread: (workspaceId: string) => Promise<unknown>;
+ sendUserMessage: (
+ workspaceId: string,
+ threadId: string,
+ text: string,
+ options?: {
+ collaborationMode?: Record<string, unknown> | null;
+ },
+ ) => Promise<unknown>;
+ onSelectThread?: (workspaceId: string, threadId: string) => void;
+ collaborationMode?: Record<string, unknown> | null;
+};
+
+type ForgeRunningInfo = {
+ taskId: string;
+ phaseId: string;
+};
+
+type ForgeExecutionState = {
+ isExecuting: boolean;
+ runningInfo: ForgeRunningInfo | null;
+ lastError: string | null;
+ startExecution: (planId: string) => Promise<void>;
+ pauseExecution: () => Promise<void>;
+};
+
+const POLL_INTERVAL_MS = 1200;
+
+function isFinalPhaseStatus(status: ForgeExecutionStatusLike): boolean {
+ const normalized = status.trim().toLowerCase();
+ return (
+ normalized === "completed" ||
+ normalized === "complete" ||
+ normalized === "done" ||
+ normalized === "success" ||
+ normalized === "succeeded" ||
+ normalized === "failed" ||
+ normalized === "blocked" ||
+ normalized === "error" ||
+ normalized === "canceled" ||
+ normalized === "cancelled"
+ );
+}
+
+function isCompletedPhaseStatus(status: ForgeExecutionStatusLike): boolean {
+ const normalized = status.trim().toLowerCase();
+ return (
+ normalized === "completed" ||
+ normalized === "complete" ||
+ normalized === "done" ||
+ normalized === "success" ||
+ normalized === "succeeded"
+ );
+}
+
+function extractThreadId(response: unknown): string | null {
+ if (!response || typeof response !== "object") {
+ return null;
+ }
+
+ const directThread = (response as { thread?: { id?: unknown } }).thread;
+ const nestedThread = (response as { result?: { thread?: { id?: unknown } } }).result?.thread;
+ const id = directThread?.id ?? nestedThread?.id ?? null;
+ if (typeof id !== "string") {
+ return null;
+ }
+
+ const trimmed = id.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function extractTurnId(response: unknown): string | null {
+ if (!response || typeof response !== "object") {
+ return null;
+ }
+
+ const directTurn = (response as { turn?: { id?: unknown } }).turn;
+ const nestedTurn = (response as { result?: { turn?: { id?: unknown } } }).result?.turn;
+ const id = directTurn?.id ?? nestedTurn?.id ?? null;
+ if (typeof id !== "string") {
+ return null;
+ }
+
+ const trimmed = id.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function summarizeStartThreadResponse(response: unknown): string {
+ if (response == null) {
+ return String(response);
+ }
+ if (typeof response !== "object") {
+ return typeof response;
+ }
+ try {
+ const serialized = JSON.stringify(response);
+ if (!serialized) {
+ return "empty object";
+ }
+ return serialized.length > 240 ? `${serialized.slice(0, 237)}...` : serialized;
+ } catch {
+ return "unserializable object";
+ }
+}
+
+function wait(ms: number): Promise<void> {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+}
+
+export function useForgeExecution({
+ workspaceId,
+ knownTaskIds = null,
+ connectWorkspace,
+ prepareExecution,
+ getNextPhasePrompt,
+ getPhaseStatus,
+ runPhaseChecks,
+ interruptTurn,
+ startThread,
+ sendUserMessage,
+ onSelectThread,
+ collaborationMode = null,
+}: ForgeExecutionArgs): ForgeExecutionState {
+ const [isExecuting, setIsExecuting] = useState(false);
+ const [runningInfo, setRunningInfo] = useState<ForgeRunningInfo | null>(null);
+ const [lastError, setLastError] = useState<string | null>(null);
+ const runTokenRef = useRef(0);
+ const activeRunRef = useRef<number | null>(null);
+ const activeTurnRef = useRef<{
+ workspaceId: string;
+ threadId: string;
+ turnId: string;
+ } | null>(null);
+ const normalizedKnownTaskIds = useMemo(() => {
+ if (!knownTaskIds) {
+ return null;
+ }
+ const next = new Set<string>();
+ for (const taskId of knownTaskIds) {
+ const trimmed = taskId.trim();
+ if (trimmed) {
+ next.add(trimmed);
+ }
+ }
+ return next;
+ }, [knownTaskIds]);
+
+ const clearExecutionState = useCallback(() => {
+ setIsExecuting(false);
+ setRunningInfo(null);
+ }, []);
+
+ const pauseExecution = useCallback(async () => {
+ const activeTurn = activeTurnRef.current;
+ activeTurnRef.current = null;
+ runTokenRef.current += 1;
+ activeRunRef.current = null;
+ clearExecutionState();
+ if (!activeTurn) {
+ return;
+ }
+
+ try {
+ await interruptTurn(
+ activeTurn.workspaceId,
+ activeTurn.threadId,
+ activeTurn.turnId || "pending",
+ );
+ } catch (error) {
+ console.warn("Forge execution pause interrupt failed.", { error });
+ }
+ }, [clearExecutionState, interruptTurn]);
+
+ useEffect(() => {
+ return () => {
+ activeTurnRef.current = null;
+ runTokenRef.current += 1;
+ activeRunRef.current = null;
+ };
+ }, []);
+
+ const startExecution = useCallback(
+ async (planId: string) => {
+ const workspace = workspaceId?.trim();
+ const normalizedPlanId = planId.trim();
+ if (!workspace || !normalizedPlanId || activeRunRef.current !== null) {
+ return;
+ }
+
+ const token = runTokenRef.current + 1;
+ runTokenRef.current = token;
+ activeRunRef.current = token;
+ activeTurnRef.current = null;
+ setIsExecuting(true);
+ setRunningInfo(null);
+ setLastError(null);
+
+ const isActive = () => activeRunRef.current === token && runTokenRef.current === token;
+
+ const waitForPhaseFinalStatus = async (
+ taskId: string,
+ phaseId: string,
+ ): Promise<ForgeExecutionStatusLike | null> => {
+ while (isActive()) {
+ const phaseStatus = await getPhaseStatus(
+ workspace,
+ normalizedPlanId,
+ taskId,
+ phaseId,
+ );
+ if (!isActive()) {
+ return null;
+ }
+ if (isFinalPhaseStatus(phaseStatus.status)) {
+ return phaseStatus.status;
+ }
+ await wait(POLL_INTERVAL_MS);
+ }
+ return null;
+ };
+
+ try {
+ await connectWorkspace(workspace);
+ if (!isActive()) {
+ return;
+ }
+
+ await prepareExecution(workspace, normalizedPlanId);
+ if (!isActive()) {
+ return;
+ }
+
+ let activeThreadId: string | null = null;
+ let activeThreadTaskId: string | null = null;
+ const threadByTaskId = new Map<string, string>();
+ const pendingKnownTaskIds = normalizedKnownTaskIds
+ ? new Set(normalizedKnownTaskIds)
+ : null;
+
+ while (isActive()) {
+ const phase = await getNextPhasePrompt(workspace, normalizedPlanId);
+ if (!isActive()) {
+ return;
+ }
+ if (!phase) {
+ return;
+ }
+ const taskId = phase.taskId.trim();
+ if (!taskId) {
+ throw new Error("Forge next phase prompt returned an empty task id.");
+ }
+ const phaseId = phase.phaseId.trim();
+ if (!phaseId) {
+ throw new Error("Forge next phase prompt returned an empty phase id.");
+ }
+
+ if (normalizedKnownTaskIds && !normalizedKnownTaskIds.has(taskId)) {
+ throw new Error(
+ `Forge execution received unexpected task id "${taskId}" not present in the selected plan.`,
+ );
+ }
+
+ const mappedThreadId = threadByTaskId.get(taskId) ?? null;
+ if (
+ !mappedThreadId &&
+ normalizedKnownTaskIds &&
+ normalizedKnownTaskIds.size > 0 &&
+ pendingKnownTaskIds &&
+ pendingKnownTaskIds.size === 0
+ ) {
+ throw new Error(
+ `Forge execution received unexpected task id "${taskId}" after exhausting visible plan tasks.`,
+ );
+ }
+ pendingKnownTaskIds?.delete(taskId);
+ if (!mappedThreadId) {
+ const previousTaskId = activeThreadTaskId;
+ const previousThreadId = activeThreadId;
+ activeTurnRef.current = null;
+ const threadResponse = await startThread(workspace);
+ if (!isActive()) {
+ return;
+ }
+
+ const threadId = extractThreadId(threadResponse);
+ if (!threadId) {
+ throw new Error(
+ `Forge execution start thread response missing thread id (response=${summarizeStartThreadResponse(
+ threadResponse,
+ )}).`,
+ );
+ }
+
+ threadByTaskId.set(taskId, threadId);
+ activeThreadId = threadId;
+ activeThreadTaskId = taskId;
+ if (
+ previousTaskId &&
+ previousTaskId !== taskId &&
+ previousThreadId &&
+ previousThreadId === threadId
+ ) {
+ console.warn("Forge execution task transition reused a thread id.", {
+ previousTaskId,
+ previousThreadId,
+ taskId,
+ threadId,
+ });
+ }
+ onSelectThread?.(workspace, threadId);
+ } else {
+ activeThreadId = mappedThreadId;
+ activeThreadTaskId = taskId;
+ }
+
+ setRunningInfo({ taskId, phaseId });
+
+ const threadId = activeThreadId;
+ if (!threadId) {
+ throw new Error("Forge execution thread id is missing.");
+ }
+ activeTurnRef.current = {
+ workspaceId: workspace,
+ threadId,
+ turnId: "pending",
+ };
+ const turnResponse = await sendUserMessage(workspace, threadId, phase.promptText, {
+ collaborationMode,
+ });
+ if (!isActive()) {
+ return;
+ }
+ const turnId = extractTurnId(turnResponse);
+ if (turnId && activeTurnRef.current?.threadId === threadId) {
+ activeTurnRef.current = {
+ workspaceId: workspace,
+ threadId,
+ turnId,
+ };
+ }
+
+ const lastPhaseStatus = await waitForPhaseFinalStatus(taskId, phaseId);
+
+ if (!isActive()) {
+ return;
+ }
+ activeTurnRef.current = null;
+
+ if (lastPhaseStatus === null) {
+ return;
+ }
+
+ if (!isCompletedPhaseStatus(lastPhaseStatus)) {
+ throw new Error(
+ `Phase ${taskId}/${phaseId} reached terminal status "${lastPhaseStatus}" before completion.`,
+ );
+ }
+
+ const checks = await runPhaseChecks(
+ workspace,
+ normalizedPlanId,
+ taskId,
+ phaseId,
+ );
+ if (!checks.ok) {
+ await wait(POLL_INTERVAL_MS);
+ continue;
+ }
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error && error.message.trim().length > 0
+ ? error.message.trim()
+ : String(error);
+ setLastError(`Forge execution failed: ${message}`);
+ console.warn("Forge execution failed.", { error });
+ } finally {
+ if (activeRunRef.current === token) {
+ activeTurnRef.current = null;
+ }
+ if (activeRunRef.current === token) {
+ activeRunRef.current = null;
+ clearExecutionState();
+ }
+ }
+ },
+ [
+ clearExecutionState,
+ collaborationMode,
+ connectWorkspace,
+ getNextPhasePrompt,
+ getPhaseStatus,
+ onSelectThread,
+ prepareExecution,
+ runPhaseChecks,
+ sendUserMessage,
+ startThread,
+ normalizedKnownTaskIds,
+ workspaceId,
+ ],
+ );
+
+ return useMemo(
+ () => ({
+ isExecuting,
+ runningInfo,
+ lastError,
+ startExecution,
+ pauseExecution,
+ }),
+ [isExecuting, lastError, pauseExecution, runningInfo, startExecution],
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| components | +
+
+ |
+ 91.08% | +756/830 | +78.57% | +176/224 | +86.95% | +20/23 | +91.08% | +756/830 | +
| hooks | +
+
+ |
+ 80.28% | +281/350 | +73.58% | +78/106 | +88.88% | +8/9 | +80.28% | +281/350 | +