@atmem/architect 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/cli/runtime.mjs +3 -1
- package/dist/assets/{index-CQ2C1gKF.js → index-D4CZ_n8f.js} +20473 -20468
- package/dist/assets/index-DuveVTIx.css +1 -0
- package/dist/assets/{index.es-DTyqIdaO.js → index.es-Fcqg-orY.js} +1 -1
- package/dist/assets/{visual-export-Ox0j5xj5.js → visual-export-4PDDTAP7.js} +3 -3
- package/dist/index.html +2 -2
- package/dist/licenses/memory-dependency-notices.txt +149 -2241
- package/package.json +2 -2
- package/public/licenses/memory-dependency-notices.txt +149 -2241
- package/server/memory-requirements.lock +6 -6
- package/server/memory-requirements.txt +2 -2
- package/server/memory_service.py +3 -1
- package/server/memory_worker.py +4 -2
- package/server/pricing.py +55 -1
- package/server/request_uploads.py +65 -0
- package/server/terraform_routes.py +47 -1
- package/src/EngineeringPanel.jsx +14 -7
- package/src/FinalChecklist.jsx +11 -3
- package/src/ImplementationDelivery.jsx +10 -7
- package/src/JourneyViews.jsx +4 -4
- package/src/OutputsView.jsx +14 -8
- package/src/ProjectChecks.jsx +35 -21
- package/src/StarPrompt.jsx +26 -0
- package/src/engineering.css +10 -0
- package/src/journey-workspace.css +5 -0
- package/src/large-request.mjs +25 -0
- package/src/main.jsx +17 -9
- package/src/output-bundle.mjs +16 -4
- package/src/pricing.mjs +6 -4
- package/src/project-file.mjs +1 -1
- package/dist/assets/index-BTLHaa3J.css +0 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# This file was autogenerated by uv via the following command:
|
|
2
2
|
# uv pip compile --universal --python-version 3.13 server/memory-requirements.txt --generate-hashes --output-file server/memory-requirements.lock
|
|
3
|
-
atflows==0.1.
|
|
4
|
-
--hash=sha256:
|
|
5
|
-
--hash=sha256:
|
|
3
|
+
atflows==0.1.8 \
|
|
4
|
+
--hash=sha256:9819783a615d8eb21ac70adc826627f707c4072d2a67f0efe2b173b06405477f \
|
|
5
|
+
--hash=sha256:fe07f07f2710e4603bd29e10b2a29cb3192f9b8e7426418da7604b88fe0e4cbc
|
|
6
6
|
# via
|
|
7
7
|
# -r server/memory-requirements.txt
|
|
8
8
|
# atmem
|
|
9
|
-
atmem==2.3.
|
|
10
|
-
--hash=sha256:
|
|
11
|
-
--hash=sha256:
|
|
9
|
+
atmem==2.3.14 \
|
|
10
|
+
--hash=sha256:37597be588817aa2dad921a683b39b6e6417eb5bdf3ec23256655acf13b19a03 \
|
|
11
|
+
--hash=sha256:e70c4baab2e41375aee6cc7d31066d2e5b9fca324d2e086649d2cabbee34708d
|
|
12
12
|
# via -r server/memory-requirements.txt
|
|
13
13
|
atmem-atbot==0.1.1 \
|
|
14
14
|
--hash=sha256:0b47efeda0ca46c4f3bcaa6ce4adabf7b5094841d788f3cfee783d668a6a03d1 \
|
package/server/memory_service.py
CHANGED
|
@@ -46,6 +46,7 @@ class MemoryService:
|
|
|
46
46
|
self.worker = str(worker or setting('MEMORY_PYTHON') or ROOT / '.memory-venv' / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python'))
|
|
47
47
|
self.lock = threading.RLock()
|
|
48
48
|
self.error = None
|
|
49
|
+
self.engine_version = None
|
|
49
50
|
with self.db() as db:
|
|
50
51
|
db.executescript('''
|
|
51
52
|
CREATE TABLE IF NOT EXISTS identity (id TEXT PRIMARY KEY);
|
|
@@ -107,6 +108,7 @@ class MemoryService:
|
|
|
107
108
|
if result.returncode or 'error' in payload:
|
|
108
109
|
raise RuntimeError('AtMem rejected the operation (' + payload.get('error', 'worker failed') + ')')
|
|
109
110
|
self.error = None
|
|
111
|
+
self.engine_version = payload.get('version')
|
|
110
112
|
return payload['results']
|
|
111
113
|
|
|
112
114
|
def enqueue(self, project, scope, key, text, event_id=None, source=''):
|
|
@@ -192,7 +194,7 @@ class MemoryService:
|
|
|
192
194
|
def status(self, project):
|
|
193
195
|
with self.db() as db:
|
|
194
196
|
counts = dict(db.execute('SELECT state,count(*) FROM queue WHERE project=? GROUP BY state', (project,)).fetchall())
|
|
195
|
-
return {'enabled': self.enabled(project), 'status': 'disabled' if not self.enabled(project) else 'unavailable' if self.error else 'ready', 'pending': counts.get('pending', 0), 'rejected': counts.get('rejected', 0), 'engine': 'AtMem
|
|
197
|
+
return {'enabled': self.enabled(project), 'status': 'disabled' if not self.enabled(project) else 'unavailable' if self.error else 'ready', 'pending': counts.get('pending', 0), 'rejected': counts.get('rejected', 0), 'engine': f'AtMem {self.engine_version} core SDK' if self.engine_version else 'AtMem core SDK (not checked)', 'policy': 'AtMem AI Architect single-user scope and opt-out policy', 'detail': self.error}
|
|
196
198
|
|
|
197
199
|
def profile(self, project, field, value):
|
|
198
200
|
if field not in {'explanation', 'experience', 'iac', 'preferences'} or not value.strip() or len(value) > 1000:
|
package/server/memory_worker.py
CHANGED
|
@@ -5,6 +5,8 @@ import sqlite3
|
|
|
5
5
|
import base64
|
|
6
6
|
import re
|
|
7
7
|
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
EXPECTED_ATMEM_VERSION = re.search(r"^atmem==(\d+\.\d+\.\d+)$", (Path(__file__).parent / "memory-requirements.txt").read_text(), re.M).group(1)
|
|
8
10
|
from atmem import Memory
|
|
9
11
|
|
|
10
12
|
|
|
@@ -62,8 +64,8 @@ def keyed_records(memory,subject,record_id,limit=10001):
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
def run(payload):
|
|
65
|
-
if version('atmem') !=
|
|
66
|
-
raise RuntimeError('AtMem AI Architect requires the tested AtMem
|
|
67
|
+
if version('atmem') != EXPECTED_ATMEM_VERSION:
|
|
68
|
+
raise RuntimeError(f'AtMem AI Architect requires the tested AtMem {EXPECTED_ATMEM_VERSION} runtime')
|
|
67
69
|
memory = Memory(payload['database'], auto_vectors=False)
|
|
68
70
|
receipts = sqlite3.connect(payload['database'] + '.receipts.sqlite')
|
|
69
71
|
receipts.execute('CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY, result TEXT)')
|
package/server/pricing.py
CHANGED
|
@@ -61,6 +61,9 @@ def storage_quote(provider,region):
|
|
|
61
61
|
if provider=='aws':return {**aws_s3(region),'provider':'aws','available':True}
|
|
62
62
|
if provider=='azure':return dict(_azure_storage(region,int(time.time()//3600)))
|
|
63
63
|
if provider=='gcp':
|
|
64
|
+
if region=='us-central1':
|
|
65
|
+
try:return dict(_google_storage_public(region,int(time.time()//3600)))
|
|
66
|
+
except (ValueError,OSError):pass
|
|
64
67
|
import os
|
|
65
68
|
key=os.getenv('GOOGLE_CLOUD_BILLING_API_KEY')
|
|
66
69
|
if key and re.fullmatch(r'[a-z]+-[a-z]+\d',region):
|
|
@@ -80,7 +83,7 @@ def storage_quote(provider,region):
|
|
|
80
83
|
price=rates[0]['unitPrice'];value=float(price.get('units',0))+float(price.get('nanos',0))/1e9
|
|
81
84
|
candidates.append({'available':True,'provider':'gcp','price':value,'currency':'USD','unit':'GiB-month','region':region,'service':'Google Cloud Storage Standard / '+sku['skuId'],'source':'Google Cloud Billing Catalog','url':'https://cloud.google.com/skus/?currency=USD','date':datetime.now(timezone.utc).date().isoformat(),'coverage':'Standard storage only; operations, replication and transfer excluded'})
|
|
82
85
|
if len(candidates)==1:return candidates[0]
|
|
83
|
-
return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'
|
|
86
|
+
return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':f'Storage pricing for {region or "the selected region"} could not be verified. Recalculate or enter a sourced rate.','nextQuestion':'Which Google Cloud region and storage class should we price?'}
|
|
84
87
|
return {'available':False,'provider':'ibm','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'IBM pricing depends on region, storage class and plan. No verified regional quote is configured; no AWS or invented rate is substituted.','nextQuestion':'Which IBM region and storage class or existing IBM quote should we use?'}
|
|
85
88
|
|
|
86
89
|
|
|
@@ -188,3 +191,54 @@ def google_public_quote(service,region,planning_reference=False):
|
|
|
188
191
|
if region!='us-central1' and not (planning_reference and (not region or re.search('select|unknown|tbd',region,re.I))):
|
|
189
192
|
raise ValueError('This public quote supports Iowa only; the selected region is preserved.')
|
|
190
193
|
return {**_google_public(service,int(time.time()//3600)),'planningReference':region!='us-central1'}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_google_storage_public(html, region, url):
|
|
197
|
+
"""Read an explicitly selected single-region public table, never a region list match."""
|
|
198
|
+
import hashlib
|
|
199
|
+
from html.parser import HTMLParser
|
|
200
|
+
if region != 'us-central1':
|
|
201
|
+
raise ValueError('The public default storage table verifies Iowa only.')
|
|
202
|
+
marker = 'id="data-storage"'
|
|
203
|
+
if marker not in html:
|
|
204
|
+
raise ValueError('Google data storage section is missing.')
|
|
205
|
+
section = html[html.index(marker):]
|
|
206
|
+
# Stop before dual-region rates. Region options alone do not verify selected pricing.
|
|
207
|
+
boundary = re.search(r'<(?:p|h[1-6])[^>]*>\s*(?:<b>)?Dual-region(?:</b>)?\s*</', section)
|
|
208
|
+
if not boundary:
|
|
209
|
+
raise ValueError('The single-region storage boundary is missing.')
|
|
210
|
+
section = section[:boundary.start()]
|
|
211
|
+
class Selected(HTMLParser):
|
|
212
|
+
def __init__(self):super().__init__();self.regions=[]
|
|
213
|
+
def handle_starttag(self,tag,attrs):
|
|
214
|
+
a=dict(attrs)
|
|
215
|
+
if a.get('role')=='option' and a.get('aria-selected')=='true':self.regions.append(a.get('data-value'))
|
|
216
|
+
selected=Selected();selected.feed(section)
|
|
217
|
+
if selected.regions != ['Iowa (us-central1)']:
|
|
218
|
+
raise ValueError('The selected public storage region is not Iowa.')
|
|
219
|
+
table = re.search(r'<table\b[^>]*>.*?</table>', section, re.S)
|
|
220
|
+
rows = _public_rows(table.group(0)) if table else []
|
|
221
|
+
if len(rows)!=2 or rows[0].count('Standard storage')!=1:
|
|
222
|
+
raise ValueError('The Standard storage table could not be verified.')
|
|
223
|
+
index=rows[0].index('Standard storage')
|
|
224
|
+
if index>=len(rows[1]):raise ValueError('The storage price is missing.')
|
|
225
|
+
cell=rows[1][index]
|
|
226
|
+
rate=re.fullmatch(r'\$([0-9]+(?:\.[0-9]+)?) / 1 gibibyte hour(?:, per 1 month / account)?',cell)
|
|
227
|
+
if not rate or not 0<float(rate.group(1))<1:raise ValueError('Unexpected Google storage rate or unit.')
|
|
228
|
+
hourly=float(rate.group(1))
|
|
229
|
+
return {'available':True,'provider':'gcp','price':hourly*730,'currency':'USD',
|
|
230
|
+
'unit':'GiB-month','region':region,'service':'Cloud Storage Standard / single-region',
|
|
231
|
+
'source':'Google Cloud public pricing','url':url,
|
|
232
|
+
'date':datetime.now(timezone.utc).date().isoformat(),
|
|
233
|
+
'sourceSha256':hashlib.sha256(section.encode()).hexdigest(),
|
|
234
|
+
'hourlyPrice':hourly,'hoursPerMonth':730,
|
|
235
|
+
'coverage':'Single-region Standard storage; 730-hour planning month. Operations, replication, transfer, taxes and discounts excluded.'}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@lru_cache(maxsize=32)
|
|
239
|
+
def _google_storage_public(region,hour):
|
|
240
|
+
url='https://cloud.google.com/storage/pricing'
|
|
241
|
+
with urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'AtMem Architect pricing'}),timeout=12) as response:
|
|
242
|
+
raw=response.read(8_000_001)
|
|
243
|
+
if len(raw)>8_000_000:raise ValueError('Google storage page exceeds its size bound.')
|
|
244
|
+
return parse_google_storage_public(raw.decode('utf-8'),region,url)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Bounded, temporary, ordered request assembly. Uploaded content is never executed."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import secrets
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
9
|
+
CHUNK_BYTES = 128 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestUploads:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.rows = {}
|
|
15
|
+
self.lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def _expire(self):
|
|
18
|
+
for key, row in list(self.rows.items()):
|
|
19
|
+
if time.monotonic() - row['updated'] > 1800:
|
|
20
|
+
self.rows.pop(key)['file'].close()
|
|
21
|
+
|
|
22
|
+
def start(self, size, digest):
|
|
23
|
+
with self.lock:
|
|
24
|
+
self._expire()
|
|
25
|
+
if not 0 < size <= MAX_BYTES:
|
|
26
|
+
raise ValueError('Request exceeds the 64 MiB upload limit.')
|
|
27
|
+
if len(self.rows) >= 8 or sum(r['size'] for r in self.rows.values()) + size > 2 * MAX_BYTES:
|
|
28
|
+
raise ValueError('Upload capacity is busy. Retry after current uploads finish.')
|
|
29
|
+
key = secrets.token_hex(24)
|
|
30
|
+
self.rows[key] = dict(file=tempfile.TemporaryFile(), size=size, digest=digest,
|
|
31
|
+
received=0, index=0, updated=time.monotonic())
|
|
32
|
+
return {'uploadId': key, 'chunkBytes': CHUNK_BYTES}
|
|
33
|
+
|
|
34
|
+
def append(self, key, index, content):
|
|
35
|
+
with self.lock:
|
|
36
|
+
self._expire()
|
|
37
|
+
row = self.rows.get(key)
|
|
38
|
+
if row is None:
|
|
39
|
+
raise ValueError('Upload expired. Start validation again.')
|
|
40
|
+
if index != row['index'] or not 0 < len(content) <= CHUNK_BYTES or row['received'] + len(content) > row['size']:
|
|
41
|
+
raise ValueError('Upload chunk is out of order or exceeds its declared size.')
|
|
42
|
+
row['file'].write(content)
|
|
43
|
+
row.update(received=row['received'] + len(content), index=index + 1, updated=time.monotonic())
|
|
44
|
+
return {'receivedBytes': row['received']}
|
|
45
|
+
|
|
46
|
+
def take(self, key):
|
|
47
|
+
with self.lock:
|
|
48
|
+
self._expire()
|
|
49
|
+
row = self.rows.pop(key, None)
|
|
50
|
+
if row is None:
|
|
51
|
+
raise ValueError('Upload expired. Start validation again.')
|
|
52
|
+
try:
|
|
53
|
+
row['file'].seek(0)
|
|
54
|
+
data = row['file'].read()
|
|
55
|
+
if len(data) != row['size'] or hashlib.sha256(data).hexdigest() != row['digest']:
|
|
56
|
+
raise ValueError('Upload is incomplete or its checksum changed. Retry validation.')
|
|
57
|
+
return data
|
|
58
|
+
finally:
|
|
59
|
+
row['file'].close()
|
|
60
|
+
|
|
61
|
+
def cancel(self, key):
|
|
62
|
+
with self.lock:
|
|
63
|
+
row = self.rows.pop(key, None)
|
|
64
|
+
if row:
|
|
65
|
+
row['file'].close()
|
|
@@ -11,13 +11,49 @@ from pathlib import Path
|
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
13
|
import tempfile
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
14
16
|
from fastapi import APIRouter, HTTPException
|
|
15
|
-
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
18
|
+
from .request_uploads import RequestUploads, MAX_BYTES
|
|
16
19
|
from typing import Literal
|
|
17
20
|
|
|
18
21
|
router = APIRouter()
|
|
19
22
|
LOCK = asyncio.Lock()
|
|
20
23
|
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
UPLOADS = RequestUploads()
|
|
25
|
+
|
|
26
|
+
class UploadStart(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra='forbid')
|
|
28
|
+
bytes: int = Field(gt=0, le=MAX_BYTES, strict=True)
|
|
29
|
+
sha256: str = Field(pattern=r'^[a-f0-9]{64}$')
|
|
30
|
+
|
|
31
|
+
class UploadId(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra='forbid')
|
|
33
|
+
uploadId: str = Field(pattern=r'^[a-f0-9]{48}$')
|
|
34
|
+
|
|
35
|
+
class UploadChunk(UploadId):
|
|
36
|
+
index: int = Field(ge=0, strict=True)
|
|
37
|
+
data: str = Field(min_length=1, max_length=174764)
|
|
38
|
+
|
|
39
|
+
@router.post('/api/terraform/uploads/start')
|
|
40
|
+
async def upload_start(payload: UploadStart):
|
|
41
|
+
try:
|
|
42
|
+
return UPLOADS.start(payload.bytes, payload.sha256)
|
|
43
|
+
except ValueError as error:
|
|
44
|
+
raise HTTPException(409, str(error))
|
|
45
|
+
|
|
46
|
+
@router.post('/api/terraform/uploads/chunk')
|
|
47
|
+
async def upload_chunk(payload: UploadChunk):
|
|
48
|
+
try:
|
|
49
|
+
return UPLOADS.append(payload.uploadId, payload.index, base64.b64decode(payload.data, validate=True))
|
|
50
|
+
except (ValueError, binascii.Error) as error:
|
|
51
|
+
raise HTTPException(422, str(error))
|
|
52
|
+
|
|
53
|
+
@router.post('/api/terraform/uploads/cancel')
|
|
54
|
+
async def upload_cancel(payload: UploadId):
|
|
55
|
+
UPLOADS.cancel(payload.uploadId)
|
|
56
|
+
return {'cancelled': True}
|
|
21
57
|
|
|
22
58
|
class ValidationRequest(BaseModel):
|
|
23
59
|
model_config = ConfigDict(extra='forbid')
|
|
@@ -116,3 +152,13 @@ async def validate_route(payload: ValidationRequest):
|
|
|
116
152
|
raise HTTPException(504, 'Terraform validation timed out. No deployment was attempted.')
|
|
117
153
|
except (ValueError, OSError):
|
|
118
154
|
raise HTTPException(422, 'Could not validate the generated Terraform draft. Check the project and local Terraform installation.')
|
|
155
|
+
|
|
156
|
+
@router.post('/api/terraform/validate-upload')
|
|
157
|
+
async def validate_upload(payload: UploadId):
|
|
158
|
+
if LOCK.locked():
|
|
159
|
+
raise HTTPException(409, 'A Terraform validation is already running.')
|
|
160
|
+
try:
|
|
161
|
+
request = ValidationRequest.model_validate_json(UPLOADS.take(payload.uploadId))
|
|
162
|
+
except (ValueError, ValidationError):
|
|
163
|
+
raise HTTPException(422, 'The upload is incomplete, changed, or is not a valid project validation request.')
|
|
164
|
+
return await validate_route(request)
|
package/src/EngineeringPanel.jsx
CHANGED
|
@@ -59,9 +59,14 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
|
|
|
59
59
|
actionLock.current=true;setBusy(true);setError('');
|
|
60
60
|
try{await operation();}catch(e){if(live.current)setError(e.message);}finally{actionLock.current=false;if(live.current)setBusy(false);}
|
|
61
61
|
}
|
|
62
|
-
useEffect(()=>{onBusyChange?.(busy);return()=>onBusyChange?.(false);},[busy,onBusyChange]);
|
|
62
|
+
useEffect(()=>{onBusyChange?.(busy||loading||['running','starting'].includes(job?.state));return()=>onBusyChange?.(false);},[busy,loading,job?.state,onBusyChange]);
|
|
63
63
|
const exactSelection=connection?.provider==='codex'&&connection.model&&connection.model!=='default'&&connection.effort;
|
|
64
64
|
const running=job?.state==='running',uncertain=job?.state==='starting';
|
|
65
|
+
const lastProgress=events.filter(event=>event.runId===job?.run&&['progress','message'].includes(event.kind)).at(-1);
|
|
66
|
+
const [lastReceived,setLastReceived]=useState(null),[now,setNow]=useState(Date.now());
|
|
67
|
+
useEffect(()=>{if(lastProgress){const time=Date.now();setLastReceived(time);setNow(time);}},[lastProgress?.sequence]);
|
|
68
|
+
useEffect(()=>{if(!running&&!uncertain)return;const timer=setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer);},[running,uncertain]);
|
|
69
|
+
const activity=lastProgress?.kind==='message'?'Preparing an update':({reasoning:'Thinking through the implementation',fileChange:'Updating project files',commandExecution:'Running a development command',mcpToolCall:'Using a development tool',webSearch:'Checking documentation'}[lastProgress?.text]||'Generating project files');
|
|
65
70
|
const conversation=engineeringConversation(events);
|
|
66
71
|
const hasDesign=hasArchitectureDesign(project),canSubmit=running?!!draft.trim():!!buildIntent(project,draft);
|
|
67
72
|
const consumedBuild=useRef(null);
|
|
@@ -112,7 +117,9 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
|
|
|
112
117
|
{restoreBlocked&&<button disabled={loading||busy} onClick={()=>{setError('');setRestoreAttempt(value=>value+1);}}>Retry loading earlier work</button>}
|
|
113
118
|
{loading?<p role="status">Finding your project’s work…</p>:<>
|
|
114
119
|
{job?<>
|
|
115
|
-
|
|
120
|
+
{(running||uncertain)&&<div className="generation-progress" role="progressbar" aria-label="Project generation" aria-valuetext={activity}><span/></div>}
|
|
121
|
+
{(running||uncertain)&&<p className="generation-activity" role="status">{activity}{lastReceived&&<> · Last update received {Math.max(0,Math.floor((now-lastReceived)/1000))}s ago</>}</p>}
|
|
122
|
+
<p className="generation-status" role="status">{(running||uncertain||busy)&&<BrainActivity size={24}/>} {running?'Working':uncertain?'Recovering acceptance of your last request':finalization?.kind==='finalization_needs_attention'?'Files generated · Checks need attention':job.last_status?({completed:'Work completed',interrupted:'Work stopped',failed:'Work needs attention'}[job.last_status]||job.last_status):'Ready'} · {job.model} · {job.effort}</p>
|
|
116
123
|
|
|
117
124
|
</>:<>
|
|
118
125
|
<p>{exactSelection?`${connection.model} · ${connection.effort}`:'Choose a Codex model and reasoning level to start.'}</p>
|
|
@@ -133,14 +140,14 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
|
|
|
133
140
|
{running&&<button disabled={busy} onClick={()=>act(async()=>{const next=await call('interrupt');if(live.current)setJob(next);})}>Stop work</button>}
|
|
134
141
|
{!running&&!uncertain&&job.last_status&&<button disabled={busy} onClick={()=>act(async()=>{const captured=await call('capture');if(live.current)setArtifact(captured);})}>Refresh package snapshot</button>}
|
|
135
142
|
</details>
|
|
136
|
-
<details><summary>Engineering progress ({events.length})</summary><ol>{events.filter(event=>!['message','accepted','steering_submitted'].includes(event.kind)).map(event=><li key={event.sequence}>{event.kind}{event.text?` · ${event.text}`:''}</li>)}</ol></details>
|
|
143
|
+
<details><summary>Engineering progress (<span key={events.length} className={`engineering-progress-count${running||uncertain?' updated':''}`}>{events.length}</span>)</summary><ol>{events.filter(event=>!['message','accepted','steering_submitted'].includes(event.kind)).map(event=><li key={event.sequence}>{event.kind}{event.text?` · ${event.text}`:''}</li>)}</ol></details>
|
|
137
144
|
</>}
|
|
138
|
-
{artifact&&<div className="engineering-artifact"><p>Project files · {artifact.files.filter(file=>file.kind==='file').length} {artifact.files.filter(file=>file.kind==='file').length===1?'file':'files'}
|
|
139
|
-
{artifact.runId!==job?.run&&<p>
|
|
145
|
+
{artifact&&<div className="engineering-artifact"><p>{artifact.runId!==job?.run||running||uncertain?'Previous files':'Project files'} · {artifact.files.filter(file=>file.kind==='file').length} {artifact.files.filter(file=>file.kind==='file').length===1?'file':'files'}</p>
|
|
146
|
+
{artifact.runId!==job?.run&&<p>{running||uncertain?'Generating updated files…':'Generate updated files to include your changes.'}</p>}
|
|
140
147
|
{onDelivery&&<button disabled={busy} onClick={onDelivery}>Review and download handoff</button>}
|
|
141
148
|
|
|
142
|
-
|
|
143
|
-
{mappingStatus==='File links missing from package.'&&<button disabled={busy||contextBusy} onClick={()=>submitIntent(null,'Add architecture-work-proposal.json binding the existing implementation files to the saved design blocks and requirements. Preserve the application and requirements. Run applicable local checks; retain failed or unexecuted checks explicitly.')}>Repair file links</button>}
|
|
149
|
+
{onWorkModelChange&&<p className="muted small" role="status">{running||uncertain?'File links update when generation finishes.':artifact.runId!==job?.run?'File links belong to the previous snapshot.':mappingStatus||'Connecting files to design blocks…'}</p>}
|
|
150
|
+
{!running&&!uncertain&&artifact.runId===job?.run&&mappingStatus==='File links missing from package.'&&<button disabled={busy||contextBusy} onClick={()=>submitIntent(null,'Add architecture-work-proposal.json binding the existing implementation files to the saved design blocks and requirements. Preserve the application and requirements. Run applicable local checks; retain failed or unexecuted checks explicitly.')}>Repair file links</button>}
|
|
144
151
|
|
|
145
152
|
|
|
146
153
|
</div>}
|
package/src/FinalChecklist.jsx
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import React from'react';
|
|
1
|
+
import React,{useState,useRef} from'react';
|
|
2
|
+
import{BrainActivity}from'./BrainActivity.jsx';
|
|
2
3
|
import{estimate}from'./estimate.mjs';
|
|
3
4
|
import{cloudMapping}from'./journey.mjs';
|
|
4
5
|
import{OPTION_NAMES}from'./pack.mjs';
|
|
5
6
|
export function FinalChecklist({project,implementation,terraform,checks=[],onNavigate}){
|
|
7
|
+
const [opening,setOpening]=useState(null),[error,setError]=useState('');const navigating=useRef(false);
|
|
8
|
+
async function open(name,target){if(navigating.current||!onNavigate)return;navigating.current=true;setError('');setOpening(name);
|
|
9
|
+
try{await onNavigate(target);}catch(e){setError(e.message||'Could not open that section. Try again.');}finally{navigating.current=false;setOpening(null);}
|
|
10
|
+
}
|
|
6
11
|
const cost=estimate(project),mapping=cloudMapping(project),requirements=!!(project.needs?.length||project.brief?.application);
|
|
7
12
|
const selectedRegion=[...(project.decisions||[])].reverse().find(d=>d.subject==='cloud.region'&&d.status==='Accepted')?.decision;
|
|
8
13
|
const country=[...(project.decisions||[])].reverse().find(d=>d.subject==='data.country'&&d.status==='Accepted')?.decision;
|
|
9
14
|
const proposed=project.nodes.filter(n=>n.data.kind!=='note'&&n.data.origin!=='existing');
|
|
10
15
|
const placements=proposed.map(n=>n.data.config?.region).filter(r=>typeof r==='string'&&r.trim()&&!/select|unknown|tbd/i.test(r));
|
|
11
16
|
const region=selectedRegion||(proposed.length&&placements.length===proposed.length?[...new Set(placements)].join(', '):null);
|
|
12
|
-
const
|
|
17
|
+
const hasEstimate=cost.expected!==null&&!cost.mixedCurrencies;
|
|
18
|
+
const excluded=new Set([...cost.lines.filter(line=>line.cost==null).map(line=>line.component),...cost.notCosted]).size;
|
|
19
|
+
const costReason=hasEstimate&&excluded?`${excluded} items excluded · View breakdown`:'';
|
|
20
|
+
const rows=[['Requirements',requirements?'Recorded':'Not recorded',requirements],['Cloud',mapping.provider?(OPTION_NAMES[mapping.provider]||mapping.provider):'Not selected',!!mapping.provider],['Data country',country||'Not selected',!!country],['Cloud region',region||'Choose region',!!region],['Cost estimate',hasEstimate?'Estimated':cost.mixedCurrencies?'Choose one currency':'Not estimated',hasEstimate],['Implementation',implementation?'Included':'Not generated',!!implementation],['Application checks',checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':'Not run',checks.length>0&&checks.every(c=>c.state==='finished'&&c.result?.status==='passed')],['Terraform checks',terraform?.status==='passed'?'Passed':terraform?.status==='failed'?'Failed':terraform?.status==='incomplete'?'Incomplete':'Not run',terraform?.status==='passed']];
|
|
13
21
|
const targets=['requirements','location','location','location','cost','implementation','checks','checks'];
|
|
14
|
-
return <section className="final-checklist" aria-label="Final checklist"><h3>Final checklist</h3><ul>{rows.map(([name,status,complete],index)=><li key={name}><button type="button" onClick={()=>
|
|
22
|
+
return <section className="final-checklist" aria-label="Final checklist" aria-busy={!!opening}><h3>Final checklist</h3><ul>{rows.map(([name,status,complete],index)=><li key={name}><button type="button" onClick={()=>open(name,targets[index])} disabled={!!opening||!onNavigate} aria-label={`${name}: ${status}. Open ${name}`}><span className={complete?'check-complete':'check-pending'} aria-label={name==='Cost estimate'&&hasEstimate?'Estimate available':complete?'Complete':'Needs attention'}>{complete?'✓':'!'}</span><span className="checklist-label">{name}{name==='Cost estimate'&&costReason&&<small>{costReason}</small>}</span><strong>{opening===name?<>Opening… <BrainActivity/></>:status}</strong><span className="checklist-arrow" aria-hidden="true">→</span></button></li>)}</ul>{opening&&<p role="status" className="checklist-loading">Opening {opening}…</p>}{error&&<p role="alert">{error}</p>}</section>;
|
|
15
23
|
}
|
|
@@ -2,12 +2,13 @@ import {unzipSync,zipSync,strToU8} from 'fflate';
|
|
|
2
2
|
import {FinalChecklist} from './FinalChecklist.jsx';
|
|
3
3
|
import {BrainActivity} from './BrainActivity.jsx';
|
|
4
4
|
import {completeProjectZip} from './final-package.mjs';
|
|
5
|
-
import React,{useEffect,useState} from 'react';
|
|
5
|
+
import React,{useEffect,useState,useRef} from 'react';
|
|
6
6
|
import {engineeringCall,verifiedHandoffDownload} from './engineering-client.mjs';
|
|
7
7
|
import {ArtifactChecks} from './ArtifactChecks.jsx';
|
|
8
8
|
|
|
9
9
|
export function ImplementationDelivery({project,session,onBuild,onSave,contextBusy=false,designBundle,onDesignDownload,onDownloaded,checkReceipt,onExplore,onNavigate}){
|
|
10
10
|
const [orcaResult,setOrcaResult]=useState(null);
|
|
11
|
+
const projectRef=useRef(project);projectRef.current=project;
|
|
11
12
|
const [prepared,setPrepared]=useState(null),[preparing,setPreparing]=useState(false);
|
|
12
13
|
const [loaded,setLoaded]=useState(null),[error,setError]=useState(''),[retry,setRetry]=useState(0),[busy,setBusy]=useState(false);
|
|
13
14
|
const report=loaded?.project===project?loaded.report:null;
|
|
@@ -23,9 +24,8 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
|
|
|
23
24
|
let stopped=false;const controller=new AbortController();setPreparing(true);setError('');
|
|
24
25
|
(async()=>{try{
|
|
25
26
|
const captured=await verifiedHandoffDownload(session,{projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id,savedDesignSha256:report.savedDesignSha256},report.artifact,{signal:controller.signal});
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if(!stopped){setPrepared({artifactId:report.artifact.id,designSha256:report.savedDesignSha256,blob});onExplore?.({project,files});}
|
|
27
|
+
const files={...unzipSync(new Uint8Array(await captured.arrayBuffer())),...Object.fromEntries(Object.entries(designBundle.files).map(([name,value])=>['design-documents/'+name,typeof value==='string'?strToU8(value):value]))};
|
|
28
|
+
if(!stopped){setPrepared({artifactId:report.artifact.id,designSha256:report.savedDesignSha256});onExplore?.({project,files});}
|
|
29
29
|
}catch(e){if(!stopped&&e.name!=='AbortError')setError(e.message);}finally{if(!stopped)setPreparing(false);}})();
|
|
30
30
|
return()=>{stopped=true;controller.abort();};
|
|
31
31
|
},[report?.artifact?.id,report?.savedDesignSha256,designBundle,project,session.token,retry]);
|
|
@@ -34,11 +34,12 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
|
|
|
34
34
|
const zipBase64=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not prepare the project ZIP.'));reader.readAsDataURL(blob);});
|
|
35
35
|
const result=await engineeringCall(session,'orca/open',{projectId:project.id,zipBase64});setOrcaResult(result);if(!result.opened)throw Error(result.message);
|
|
36
36
|
}
|
|
37
|
+
async function downloadDesign(){if(busy||contextBusy)return;setBusy(true);setError('');try{await onDesignDownload?.(designBundle);}catch(error){setError(error.message);}finally{setBusy(false);}}
|
|
37
38
|
async function openOrca(){
|
|
38
39
|
if(busy||contextBusy)return;
|
|
39
40
|
setOrcaResult(null);
|
|
40
41
|
if(report?.artifact)return download(true);
|
|
41
|
-
setBusy(true);setError('');try{if(!designBundle||designBundle.project!==project)throw Error('Project files are still preparing.');if(onSave)await onSave(project);await launchOrca(new Blob([zipSync(Object.fromEntries(Object.entries(
|
|
42
|
+
setBusy(true);setError('');try{if(!designBundle||designBundle.project!==project)throw Error('Project files are still preparing.');if(onSave)await onSave(project);const full=await(designBundle.prepare?.()||designBundle);if(projectRef.current!==project)throw Error('Design changed while preparing files. Try again.');await launchOrca(new Blob([zipSync(Object.fromEntries(Object.entries(full.files).map(([name,value])=>[name,typeof value==='string'?strToU8(value):value])))]));}catch(error){setError(error.message);}finally{setBusy(false);}
|
|
42
43
|
}
|
|
43
44
|
async function download(openInOrca=false){
|
|
44
45
|
if(!report?.artifact||busy||contextBusy)return;
|
|
@@ -59,7 +60,9 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
|
|
|
59
60
|
const payload={projectId:project.id,sessionId:current.session.id,artifactId:current.artifact.id};
|
|
60
61
|
const captured=await verifiedHandoffDownload(session,{...payload,savedDesignSha256:current.savedDesignSha256},current.artifact);
|
|
61
62
|
if(designBundle?.project!==project)throw Error('The design documents are refreshing. Try downloading again when preparation finishes.');
|
|
62
|
-
const
|
|
63
|
+
const full=await(designBundle.prepare?.()||designBundle);
|
|
64
|
+
if(projectRef.current!==project)throw Error('Design changed while preparing files. Try again.');
|
|
65
|
+
const blob=await completeProjectZip(captured,full);
|
|
63
66
|
if(openInOrca){await launchOrca(blob);return;}
|
|
64
67
|
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;
|
|
65
68
|
link.download=`architect-handoff-${report.artifact.id}.zip`;link.click();onDownloaded?.();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
|
@@ -69,7 +72,7 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
|
|
|
69
72
|
<h3>Your project ZIP</h3>
|
|
70
73
|
{!report&&!error&&<p role="status">Preparing package…</p>}
|
|
71
74
|
{report&&!report.artifact&&<p>Design package · implementation not generated.</p>}
|
|
72
|
-
{report&&!report.canPackage&&<button className="btn primary" disabled={
|
|
75
|
+
{report&&!report.canPackage&&<button className="btn primary" disabled={busy||!designBundle||designBundle.project!==project||contextBusy} onClick={downloadDesign}>{busy?'Preparing ZIP…':'Download project ZIP'}</button>}
|
|
73
76
|
{report?.artifact&&<>
|
|
74
77
|
<p className="delivery-state">{report.relation==='matches_saved_design'?'✓ Current design':report.relation==='design_changed'?'! Design changed since generation':'! Earlier or unverified implementation'}</p>
|
|
75
78
|
|
package/src/JourneyViews.jsx
CHANGED
|
@@ -92,7 +92,7 @@ export function ReviewView({ project, findings, onChange, onOpen, onAddRequireme
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export function EstimateView({ project, result, onChange, onEstimate, busy=false, pricing, onNext }) {
|
|
95
|
+
export function EstimateView({ project, result, onChange, onEstimate, busy=false, pricing, onNext, onChooseRegion }) {
|
|
96
96
|
const e = result;
|
|
97
97
|
return (
|
|
98
98
|
<section className="doc" aria-label="Estimate">
|
|
@@ -104,7 +104,7 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
|
|
|
104
104
|
<div className="row">{PROJECT_SIZES.map(size=><button type="button" className={project.estimate?.projectSize===size.id?"btn primary":"btn"} key={size.id} aria-pressed={project.estimate?.projectSize===size.id} onClick={()=>{if(project.estimate?.projectSize===size.id)return;const next=setProjectSize(project,size.id);onChange(next);onEstimate?.(next);}}>{project.estimate?.projectSize===size.id&&<Check size={16} aria-hidden="true"/>}{size.label}</button>)}</div>
|
|
105
105
|
<details className="cost-assumptions"><summary>Planning assumptions</summary><p className="muted small">Small: 10,000 requests / 10 GB; Medium: 100,000 / 100 GB; Large: 1 million / 1 TB; Extra Large: 10 million / 10 TB per month. Existing usage figures take priority. Model assumptions: 3,000 tokens per request and 730 endpoint hours; training usage needs separate agreement.</p></details>
|
|
106
106
|
</fieldset>
|
|
107
|
-
{project.estimate?.projectSize&&<p className="cost-selection-status" role="status">{PROJECT_SIZES.find(size=>size.id===project.estimate.projectSize)?.label} · {pricing?.busy?'Updating prices…':e.
|
|
107
|
+
{project.estimate?.projectSize&&<p className="cost-selection-status" role="status">{PROJECT_SIZES.find(size=>size.id===project.estimate.projectSize)?.label} · {pricing?.busy?'Updating prices…':e.expected!==null?'Estimate ready':'Prices unavailable'}</p>}{pricing?.errors?.map((error,i)=><p className={e.expected!==null?"muted small":"warn-text"} key={i}>{error.component&&`${error.component}: `}{error.reason} <button className="link" disabled={busy||pricing?.busy} onClick={()=>error.action==='choose-region'?onChooseRegion?.():onEstimate?.(project)}>{error.action==='choose-region'?'Choose region':'Retry pricing'}</button></p>)}
|
|
108
108
|
|
|
109
109
|
<div className="totals">
|
|
110
110
|
<div>
|
|
@@ -119,8 +119,8 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
|
|
|
119
119
|
</div>
|
|
120
120
|
<div>
|
|
121
121
|
<small className="muted">Status</small>
|
|
122
|
-
<span className={e.
|
|
123
|
-
{e.
|
|
122
|
+
<span className={e.mixedCurrencies ? "warn-text" : e.expected!==null ? "" : "muted"}>
|
|
123
|
+
{e.mixedCurrencies ? "Choose one currency" : e.expected!==null ? "Planning estimate" : "Not estimated"}
|
|
124
124
|
</span>
|
|
125
125
|
</div>
|
|
126
126
|
{e.budget && (
|
package/src/OutputsView.jsx
CHANGED
|
@@ -2,7 +2,7 @@ import {FileTree} from './FileTree.jsx';
|
|
|
2
2
|
import {strFromU8} from 'fflate';
|
|
3
3
|
import {CodePreview} from './CodePreview.jsx';
|
|
4
4
|
import {BrainActivity} from "./BrainActivity.jsx";
|
|
5
|
-
import React,{useEffect,useState} from 'react';
|
|
5
|
+
import React,{useEffect,useState,useRef,useMemo} from 'react';
|
|
6
6
|
import {EmulationPanel} from './EmulationPanel.jsx';
|
|
7
7
|
import {outputBundle} from './output-bundle.mjs';
|
|
8
8
|
import {ImplementationDelivery} from './ImplementationDelivery.jsx';
|
|
@@ -37,16 +37,22 @@ function Preview({name,content}){
|
|
|
37
37
|
}
|
|
38
38
|
export function OutputsView({project,run,environment,session,onDownload,onBack,emulation,onDiscuss,discussionBusy,onBuild,onSave,onDownloaded,onReview,checkReceipt,onNavigate}){
|
|
39
39
|
const [exploration,setExploration]=useState(null);
|
|
40
|
+
const projectRef=useRef(project);projectRef.current=project;
|
|
41
|
+
const [packing,setPacking]=useState(false);
|
|
40
42
|
const [bundle,setBundle]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[retry,setRetry]=useState(0),[selected,setSelected]=useState('spec-kit/implementation-spec.md');
|
|
41
43
|
useEffect(()=>{
|
|
42
44
|
let active=true;setLoading(true);setError('');
|
|
43
|
-
outputBundle(project,run,environment,checkReceipt).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
|
|
45
|
+
outputBundle(project,run,environment,checkReceipt,{deferVisuals:true}).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
|
|
44
46
|
return ()=>{active=false;};
|
|
45
47
|
},[project,run,environment,retry,checkReceipt]);
|
|
46
48
|
const readiness=bundle?.files['spec-kit/readiness.json']?JSON.parse(bundle.files['spec-kit/readiness.json']):null;
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
49
|
+
const artifactMode=exploration?.project===project,prefix=artifactMode?'design-documents/':'';
|
|
50
|
+
const fileValues=useMemo(()=>artifactMode?{...exploration.files,...Object.fromEntries(Object.entries(bundle?.files||{}).map(([name,value])=>[prefix+name,value]))}:bundle?.files||{},[artifactMode,exploration,bundle,prefix]);
|
|
51
|
+
const treeFiles=useMemo(()=>({...Object.fromEntries((bundle?.visualPaths||[]).map(path=>[prefix+path,null])),...fileValues}),[bundle,fileValues,prefix]);
|
|
52
|
+
async function selectFile(name){setSelected(name);if(treeFiles[name]===null){try{const full=await bundle.prepare();if(projectRef.current===project)setBundle(full);}catch(error){if(projectRef.current===project)setError(error.message);}}}
|
|
53
|
+
async function downloadDesign(){if(packing)return;setPacking(true);setError('');try{const full=await bundle.prepare();if(projectRef.current!==project)return;setBundle(full);await onDownload(full);}catch(error){if(projectRef.current===project)setError(error.message);}finally{setPacking(false);}}
|
|
54
|
+
const selectedPath=Object.hasOwn(treeFiles,selected)?selected:fileValues['START_HERE.md']?'START_HERE.md':Object.keys(fileValues).find(name=>name.endsWith('implementation-spec.md'))||Object.keys(fileValues)[0];
|
|
55
|
+
const raw=treeFiles[selectedPath],content=typeof raw==='string'?raw:raw&&/\.(?:md|txt|json|py|js|mjs|tf|sh|yaml|yml|toml|html|css)$/.test(selectedPath)?strFromU8(raw):raw;
|
|
50
56
|
|
|
51
57
|
return <section className="doc outputs-view" aria-label="Outputs">
|
|
52
58
|
<header className="doc-head"><h2>Delivery</h2></header>
|
|
@@ -54,7 +60,7 @@ export function OutputsView({project,run,environment,session,onDownload,onBack,e
|
|
|
54
60
|
{session?.engineeringEnabled&&<ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy} designBundle={loading?null:bundle} onDesignDownload={onDownload} onDownloaded={onDownloaded} checkReceipt={checkReceipt} onExplore={setExploration} onNavigate={onNavigate}/>}
|
|
55
61
|
<section aria-label="Project file explorer"><h3>Explore files</h3>
|
|
56
62
|
|
|
57
|
-
{!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error} onClick={
|
|
63
|
+
{!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error||packing} onClick={downloadDesign}>{packing?'Preparing ZIP…':'Download project ZIP'}</button></>}
|
|
58
64
|
|
|
59
65
|
{loading&&<p role="status"><BrainActivity/> Preparing the current outputs…</p>}
|
|
60
66
|
{error&&<div role="alert"><p>{error}</p><button className="btn" onClick={()=>setRetry(n=>n+1)}>Retry preparing outputs</button></div>}
|
|
@@ -62,8 +68,8 @@ export function OutputsView({project,run,environment,session,onDownload,onBack,e
|
|
|
62
68
|
<p className="muted small">r{bundle.manifest.designRevision} · {Object.keys(bundle.files).length} design files</p>
|
|
63
69
|
{readiness&&<details><summary>Remaining work</summary><aside aria-label="Handoff readiness"><strong>Engineering handoff — decisions and implementation remain</strong><p>{readiness.counts.priorityDecisions} priority decisions · {readiness.counts.generatedFoundations} generated foundations / {readiness.counts.components} logical components</p><p>Next: {readiness.nextAction}</p><button className="link" onClick={()=>setSelected('spec-kit/readiness.md')}>Review handoff gaps</button></aside></details>}
|
|
64
70
|
<div className="outputs-browser" data-snapshot-id={bundle.manifest.snapshotId}>
|
|
65
|
-
<FileTree files={
|
|
66
|
-
<div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
|
|
71
|
+
<FileTree files={treeFiles} selected={selectedPath} onSelect={selectFile}/>
|
|
72
|
+
<div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{raw===null?<p role="status"><BrainActivity/> Preparing preview…</p>:content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
|
|
67
73
|
</div>
|
|
68
74
|
</>}
|
|
69
75
|
</section>
|