@atmem/architect 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +8 -2
  2. package/cli/runtime.mjs +3 -1
  3. package/dist/assets/{index-CQ2C1gKF.js → index-CWUupGC1.js} +20443 -20443
  4. package/dist/assets/index-DBMP6Pxk.css +1 -0
  5. package/dist/assets/{index.es-DTyqIdaO.js → index.es-H4csXVqE.js} +1 -1
  6. package/dist/assets/{visual-export-Ox0j5xj5.js → visual-export-D0f1PbRp.js} +3 -3
  7. package/dist/index.html +2 -2
  8. package/dist/licenses/memory-dependency-notices.txt +149 -2241
  9. package/dist/reference-example.zip +0 -0
  10. package/package.json +4 -2
  11. package/public/licenses/memory-dependency-notices.txt +149 -2241
  12. package/server/memory-requirements.lock +6 -6
  13. package/server/memory-requirements.txt +2 -2
  14. package/server/memory_service.py +3 -1
  15. package/server/memory_worker.py +4 -2
  16. package/server/pricing.py +55 -1
  17. package/server/request_uploads.py +65 -0
  18. package/server/terraform_routes.py +47 -1
  19. package/src/EngineeringPanel.jsx +14 -7
  20. package/src/FinalChecklist.jsx +11 -3
  21. package/src/ImplementationDelivery.jsx +10 -7
  22. package/src/JourneyViews.jsx +4 -4
  23. package/src/OpenAIVoicePanel.jsx +3 -2
  24. package/src/OutputsView.jsx +18 -9
  25. package/src/ProjectChecks.jsx +35 -21
  26. package/src/ReadyExample.jsx +16 -0
  27. package/src/StarPrompt.jsx +26 -0
  28. package/src/engineering.css +10 -0
  29. package/src/journey-workspace.css +5 -0
  30. package/src/large-request.mjs +25 -0
  31. package/src/main.jsx +64 -29
  32. package/src/onboarding.css +10 -0
  33. package/src/output-bundle.mjs +16 -4
  34. package/src/pricing.mjs +6 -4
  35. package/src/project-file.mjs +1 -1
  36. package/src/ready-example.mjs +5 -0
  37. package/src/reference-example/project.json +1440 -0
  38. package/src/reference-example/receipt.json +24 -0
  39. package/dist/assets/index-BTLHaa3J.css +0 -1
@@ -1,43 +1,57 @@
1
- import React,{useEffect,useMemo,useRef,useState}from'react';
1
+ import React,{useEffect,useMemo,useRef,useState,useId}from'react';
2
+ import{postLargeJson}from'./large-request.mjs';
2
3
  import{BrainActivity}from'./BrainActivity.jsx';
3
4
  import{engineeringCall}from'./engineering-client.mjs';
4
5
  import{terraformSketch}from'./terraform.mjs';
5
6
  import{verifiedReceipt}from'./validation-receipt.mjs';
6
- export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt}){
7
- const [report,setReport]=useState(null),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
7
+ export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt,generationPending=false}){
8
+ const [report,setReport]=useState(null),[catalogReady,setCatalogReady]=useState(false),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
9
+ const hintId=useId();
10
+ const [uploadProgress,setUploadProgress]=useState(null);
8
11
  const active=useRef(null),locked=useRef(false),files=useMemo(()=>terraformSketch(project,environment),[project,environment]),key=JSON.stringify(files);
9
12
  useEffect(()=>{let stopped=false;setResult(null);active.current?.abort();let previous=receipt;try{previous=previous||JSON.parse(localStorage.getItem('atmem-tf-receipt:'+project.id)||'null');}catch{}void verifiedReceipt(files,previous).then(value=>{if(!stopped){setResult(value);onReceipt?.(value);}});return()=>{stopped=true;active.current?.abort();};},[key]);
10
13
  useEffect(()=>{
14
+ setReport(null);setCatalog([]);setChecks([]);setCatalogReady(false);
11
15
  if(!session?.engineeringEnabled)return;
12
16
  let stopped=false,timer;const controller=new AbortController();
13
17
  async function poll(){try{
14
18
  const value=await engineeringCall(session,'delivery',{projectId:project.id},{signal:controller.signal});if(stopped)return;setReport(value);
15
- if(value.artifact){const payload={projectId:project.id,sessionId:value.session.id};const [definitions,results]=await Promise.all([engineeringCall(session,'validators',payload,{signal:controller.signal}),engineeringCall(session,'validation/list',{...payload,artifactId:value.artifact.id},{signal:controller.signal})]);if(!stopped){setCatalog(definitions.validators||[]);setChecks(results.checks||[]);}}
19
+ if(value.artifact){const payload={projectId:project.id,sessionId:value.session.id};const [definitions,results]=await Promise.all([engineeringCall(session,'validators',payload,{signal:controller.signal}),engineeringCall(session,'validation/list',{...payload,artifactId:value.artifact.id},{signal:controller.signal})]);if(!stopped){setCatalog(definitions.validators||[]);setChecks(results.checks||[]);setCatalogReady(true);}}else{setCatalog([]);setChecks([]);setCatalogReady(false);}
16
20
  }catch(e){if(!stopped&&e.name!=='AbortError'&&e.status!==404)setError(e.message);}finally{if(!stopped)timer=setTimeout(poll,2000);}}
17
21
  void poll();return()=>{stopped=true;clearTimeout(timer);controller.abort();};
18
22
  },[project.id,session?.token,session?.engineeringEnabled]);
19
23
  const running=checks.some(c=>c.state!=='finished'&&c.state!=='recovery_required');
20
- useEffect(()=>{onBusy?.(working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
21
- async function run(){
22
- if(locked.current)return;locked.current=true;setWorking(true);setError('');const controller=new AbortController();active.current=controller;
24
+ useEffect(()=>{onBusy?.(!!working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
25
+ const available=catalog.filter(v=>v.available!==false);
26
+ async function run(kind){
27
+ if(generationPending||locked.current||!session?.token||kind==='application'&&(!report?.artifact||!available.length))return;
28
+ locked.current=true;setWorking(kind);setError('');const controller=new AbortController();active.current=controller;
23
29
  try{
24
- const jobs=[];
25
- if(report?.artifact)for(const validator of catalog.filter(v=>v.available!==false)){
26
- const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
27
- if(previous&&previous.state!=='finished')continue;
28
- jobs.push(engineeringCall(session,'validation/start',{projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id,validatorId:validator.id,retry:!!previous},{signal:controller.signal}));
30
+ if(kind==='application'){
31
+ const payload={projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id};
32
+ await Promise.all(available.map(validator=>{
33
+ const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
34
+ if(previous&&previous.state!=='finished')return;
35
+ return engineeringCall(session,'validation/start',{...payload,validatorId:validator.id,retry:!!previous},{signal:controller.signal});
36
+ }));
37
+ const results=await engineeringCall(session,'validation/list',payload,{signal:controller.signal});
38
+ if(!controller.signal.aborted)setChecks(results.checks||[]);
39
+ }else{
40
+ const value=await postLargeJson('/api/terraform/validate',{project,environment},{token:session.token,signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)]),onProgress:setUploadProgress});
41
+ const verified=await verifiedReceipt(files,value);if(!controller.signal.aborted){setResult(value);onReceipt?.(verified);try{if(verified)localStorage.setItem('atmem-tf-receipt:'+project.id,JSON.stringify(verified));else localStorage.removeItem('atmem-tf-receipt:'+project.id);}catch{}}
29
42
  }
30
- const outcomesPending=Promise.allSettled(jobs);
31
- const response=await fetch('/api/terraform/validate',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({project,environment}),signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)])});const value=await response.json();
32
- if(!response.ok)throw Error(value.detail||'Terraform checks could not start.');
33
- const verified=await verifiedReceipt(files,value);if(!controller.signal.aborted){setResult(value);onReceipt?.(verified);try{if(verified)localStorage.setItem('atmem-tf-receipt:'+project.id,JSON.stringify(verified));else localStorage.removeItem('atmem-tf-receipt:'+project.id);}catch{}}
34
- const outcomes=await outcomesPending;const failed=outcomes.find(o=>o.status==='rejected');if(failed)throw failed.reason;
35
- }catch(e){if(!controller.signal.aborted){setResult({status:'failed',message:e.message});setError(e.message);}}
36
- finally{locked.current=false;setWorking(false);}
43
+ }catch(e){if(!controller.signal.aborted){setError(e.message);}}
44
+ finally{locked.current=false;setWorking(false);setUploadProgress(null);}
37
45
  }
38
46
  const label=status=>({passed:'Passed',failed:'Failed',incomplete:'Incomplete',unavailable:'Not run'}[status]||'Not run');
39
- return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3><button className="btn" disabled={!session?.token||working||running} onClick={run}>{working||running?<><BrainActivity size={22}/> Checking…</>:'Run checks'}</button></div>
40
- <ul className="check-rows"><li><span>Terraform</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working?'Running…':label(result?.status)}</strong></li>{checks.map(c=><li key={c.id}><span>{c.label}</span><strong className={c.result?.status==='passed'?'check-complete':'check-pending'}>{c.state==='finished'?label(c.result?.status):c.state==='recovery_required'?'Needs recovery':'Running…'}</strong></li>)}{!checks.length&&<li><span>Application checks</span><strong className="check-pending">{report?.artifact?'Not configured':'Generate files first'}</strong></li>}</ul>
47
+ const terraformHint=generationPending?'Project files are being generated. Wait until generation finishes.':'Validate the Terraform draft from your saved design.';
48
+ const applicationHint=generationPending?'Project files are being generated. Wait until generation finishes.':!report?.artifact?'Generate project files first.':!catalogReady?'Loading available application tests.':!available.length?'No application tests are configured.':'Run the generated application tests.';
49
+ const applicationStatus=generationPending?'Generating files…':!report?.artifact?'Generate files first':working==='application'||running?'Running…':checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':!catalogReady?'Loading…':available.length?'Not run':'Not configured';
50
+ return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3></div>
51
+ <ul className="check-rows">
52
+ <li><span>Terraform validation</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working==='terraform'?uploadProgress!==null?`Uploading ${uploadProgress}%`:'Running…':label(result?.status)}</strong><span className="check-run-action" title={terraformHint} tabIndex={generationPending?0:undefined} aria-label={terraformHint}><button className="btn" aria-label="Run Terraform validation" title={terraformHint} disabled={generationPending||!session?.token||!!working||running} onClick={()=>run('terraform')}>{working==='terraform'?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
53
+ <li><span>Application tests</span><strong id={hintId} className={applicationStatus==='Passed'?'check-complete':'check-pending'}>{applicationStatus}</strong><span className="check-run-action" title={applicationHint} tabIndex={generationPending||!report?.artifact||!available.length?0:undefined} aria-label={applicationHint}><button className="btn" aria-label="Run application tests" aria-describedby={hintId} title={applicationHint} disabled={generationPending||!session?.token||!!working||running||!report?.artifact||!available.length} onClick={()=>run('application')}>{working==='application'||running?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
54
+ </ul>
41
55
  {(result||checks.length>0)&&<details><summary>Check details</summary>{result&&<><p>{result.message}</p>{result.checks?.map((c,i)=><details key={i}><summary>{c.name} · {c.passed?'Passed':'Failed'}</summary><pre>{c.output}</pre></details>)}</>}{checks.map(c=><details key={c.id}><summary>{c.label}</summary><pre>{c.result?.output||c.result?.reason||c.state}</pre></details>)}</details>}
42
56
  {error&&<p role="alert">{error}</p>}
43
57
  </section>;
@@ -0,0 +1,16 @@
1
+ import React,{useState} from 'react';
2
+ import receipt from './reference-example/receipt.json';
3
+ export function ReadyExampleDownload({compact=false}){
4
+ const [busy,setBusy]=useState(false),[error,setError]=useState('');
5
+ async function download(){setBusy(true);setError('');try{
6
+ const response=await fetch(receipt.url);if(!response.ok)throw Error('Example download unavailable. Retry.');
7
+ const bytes=await response.arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');
8
+ if(hash!==receipt.sha256)throw Error('Example integrity check failed. Retry.');
9
+ const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'})),link=document.createElement('a');link.href=url;link.download='atmem-ai-complete-example.zip';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);
10
+ }catch(e){setError(e.message)}finally{setBusy(false)}}
11
+ return <><button type="button" className={compact?'btn ready-download':'btn primary'} onClick={download} disabled={busy}>{busy?'Preparing…':compact?'Download example ZIP':'Download complete example'}</button>{error&&<p role="alert">{error}</p>}</>;
12
+ }
13
+ export function ReadyExampleChecklist(){
14
+ const rows=[['Requirements','Recorded'],['Cloud','AWS'],['Data country','Australia'],['Cloud region','Sydney'],['Cost worksheet','USD 7.56/month · sample'],['Implementation','Source + built runtime'],['Application checks','13 local tests passed'],['Terraform checks','Validated · 2 mock plans passed']];
15
+ return <section className="final-checklist ready-reference" aria-label="Prepared example checklist"><h3>Complete reference example</h3><p>These checks apply to the bundled example ZIP. Your edited project has separate checks.</p><ul>{rows.map(([name,value])=><li key={name}><span className="check-complete">✓</span><span>{name}</span><strong>{value}</strong></li>)}</ul><ReadyExampleDownload/><details><summary>Evidence and scope</summary><p>Local fixtures and mocked AWS. Live deployment and real tenant integration have not run.</p><p>Illustrative AWS usage; assumptions and official price snapshots are included.</p></details></section>;
16
+ }
@@ -0,0 +1,26 @@
1
+ import React,{useEffect,useRef}from'react';
2
+ import{Star,X}from'lucide-react';
3
+ export const STAR_URL='https://github.com/aetna000/atmem-ai-architect';
4
+ const KEY='atmem-architect-star-prompt-v1',SNOOZE=30*24*60*60*1000;
5
+ export function shouldOfferStar(value,now=Date.now()){
6
+ if(!value)return true;
7
+ try{const record=JSON.parse(value);return record.choice!=='opened'&&(!Number.isFinite(record.after)||record.after<=now);}catch{return true;}
8
+ }
9
+ export function StarPrompt({offer=0}){
10
+ const dialog=useRef(null),choice=useRef('later');
11
+ function dismiss(value='later'){
12
+ choice.current=value;
13
+ try{localStorage.setItem(KEY,JSON.stringify({choice:value,after:Date.now()+SNOOZE}));}catch{}
14
+ dialog.current?.close();
15
+ }
16
+ useEffect(()=>{
17
+ if(!offer)return;
18
+ let allowed=true;try{allowed=shouldOfferStar(localStorage.getItem(KEY));}catch{}
19
+ if(allowed&&!dialog.current.open){choice.current='later';dialog.current.showModal();}
20
+ },[offer]);
21
+ return <dialog ref={dialog} className="star-prompt" aria-labelledby="star-prompt-title" onCancel={event=>{event.preventDefault();dismiss();}}>
22
+ <header><h2 id="star-prompt-title"><Star size={22} aria-hidden="true"/> Enjoying Architect?</h2><button type="button" className="icon" aria-label="Close star invitation" onClick={()=>dismiss()}><X size={20}/></button></header>
23
+ <p>If Architect helped, a GitHub star helps others find it.</p>
24
+ <div className="star-prompt-actions"><a className="btn primary" href={STAR_URL} target="_blank" rel="noopener noreferrer" onClick={()=>dismiss('opened')}><Star size={18} aria-hidden="true"/> Star on GitHub</a><button type="button" className="btn" onClick={()=>dismiss()}>Later</button></div>
25
+ </dialog>;
26
+ }
@@ -29,3 +29,13 @@
29
29
  .architecture-work > ul { max-height: 24rem; overflow: auto; padding-left: 1.3rem; }
30
30
  .architecture-work pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8rem; }
31
31
  .architecture-work small { display: block; }
32
+
33
+ .generation-progress { height: 8px; overflow: hidden; border-radius: 999px; background: var(--line, #e1e6ee); margin: 1rem 0 .5rem; }
34
+ .generation-progress > span { display: block; height: 100%; width: 35%; border-radius: inherit; background: var(--action, #f3a2b4); animation: generation-sweep 1.8s ease-in-out infinite; }
35
+ @keyframes generation-sweep { from { transform: translateX(-80%); } to { transform: translateX(266%); } }
36
+ @media (prefers-reduced-motion: reduce) { .generation-progress > span { animation: none; width: 100%; opacity: .65; } }
37
+
38
+ .engineering-progress-count { display: inline-block; font-variant-numeric: tabular-nums; transform-origin: center; }
39
+ .engineering-progress-count.updated { animation: progress-count-pulse 360ms ease-out; }
40
+ @keyframes progress-count-pulse { 0% { opacity: .55; transform: scale(.92); } 45% { opacity: 1; transform: scale(1.1); } 100% { transform: scale(1); } }
41
+ @media (prefers-reduced-motion: reduce) { .engineering-progress-count.updated { animation: none; } }
@@ -104,3 +104,8 @@
104
104
  .memory-modal{height:auto!important;max-height:88vh!important}.memory-profile-bar input,.memory-toolbar input,.memory-toolbar select,.memory-preference-form input,.memory-preference-form select{font:inherit;font-size:.85rem;min-height:36px;box-sizing:border-box;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}.memory-table th:last-child{width:84px;white-space:nowrap}.memory-table td:last-child{white-space:nowrap}.memory-table th:nth-child(4){width:90px}.memory-table th:nth-child(5){width:130px}.memory-table th:nth-child(3){width:84px}.memory-toolbar>strong{color:var(--muted);font-weight:500}.memory-pagination .btn{min-height:32px}.memory-record-detail .memory-table{table-layout:auto}.memory-table .memory-content:hover{text-decoration:underline;text-underline-offset:3px}
105
105
 
106
106
  .location-region-form{display:flex;align-items:flex-end;gap:12px}.location-region-form label{display:flex;flex-direction:column;gap:6px}.location-region-form input{font:inherit;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}
107
+
108
+ .final-checklist li>button:not(:disabled),.final-checklist li>button:not(:disabled) *{cursor:pointer}.final-checklist[aria-busy="true"] li>button,.final-checklist[aria-busy="true"] li>button *{cursor:wait}.final-checklist li>button:disabled{opacity:1}.checklist-label{display:flex;flex-direction:column;gap:4px;min-width:0}.checklist-label small{font-size:.75rem;color:var(--muted);font-weight:400;overflow-wrap:anywhere}.checklist-loading{font-size:.85rem;color:var(--muted)}
109
+ .star-prompt{width:min(460px,calc(100vw - 32px));max-width:460px;border:1px solid var(--line);border-radius:16px;padding:22px;background:var(--paper);color:var(--ink);box-shadow:0 18px 60px #0003}.star-prompt::backdrop{background:#0005}.star-prompt header,.star-prompt h2,.star-prompt-actions{display:flex;align-items:center;gap:10px}.star-prompt header{justify-content:space-between}.star-prompt h2{font-size:1.2rem}.star-prompt h2>svg{color:var(--accent);fill:var(--accent-soft)}.star-prompt p{margin:18px 0;line-height:1.5}.star-prompt-actions>a{flex:1;text-decoration:none}.star-prompt-actions>button{min-width:90px}
110
+
111
+ .check-run-action{display:inline-flex;margin-left:12px;flex-shrink:0}.check-run-action>button:disabled{pointer-events:none}.check-run-action:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
@@ -0,0 +1,25 @@
1
+ // Preserve the entire JSON payload; chunk transport changes no project content.
2
+ export async function postLargeJson(url,payload,{token,signal,onProgress,fetcher=fetch}={}){
3
+ const data=new TextEncoder().encode(JSON.stringify(payload));
4
+ if(data.length>64*1024*1024)throw Error('Project exceeds the 64 MiB validation upload limit.');
5
+ const post=async(path,value,requestSignal=signal)=>{
6
+ const response=await fetcher(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify(value),signal:requestSignal});
7
+ const result=await response.json();if(!response.ok)throw Error(result.detail||'Validation request could not start.');return result;
8
+ };
9
+ if(data.length<=192000)return post(url,payload);
10
+ const sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',data))].map(x=>x.toString(16).padStart(2,'0')).join('');
11
+ const {uploadId,chunkBytes}=await post('/api/terraform/uploads/start',{bytes:data.length,sha256});
12
+ try{
13
+ if(chunkBytes!==131072)throw Error('The upload service returned an unsupported chunk size.');
14
+ for(let offset=0,index=0;offset<data.length;offset+=chunkBytes,index++){
15
+ const chunk=data.subarray(offset,offset+chunkBytes);let text='';
16
+ for(let i=0;i<chunk.length;i+=8192)text+=String.fromCharCode(...chunk.subarray(i,i+8192));
17
+ await post('/api/terraform/uploads/chunk',{uploadId,index,data:btoa(text)});
18
+ onProgress?.(Math.round(Math.min(offset+chunkBytes,data.length)/data.length*100));
19
+ }
20
+ onProgress?.(null);
21
+ return await post(url+'-upload',{uploadId});
22
+ }finally{
23
+ await post('/api/terraform/uploads/cancel',{uploadId},AbortSignal.timeout(5000)).catch(()=>{});
24
+ }
25
+ }
package/src/main.jsx CHANGED
@@ -1,3 +1,7 @@
1
+ import {readyExample,isReadyExample} from './ready-example.mjs';
2
+ import {ReadyExampleDownload} from './ReadyExample.jsx';
3
+ import {StarPrompt,STAR_URL} from './StarPrompt.jsx';
4
+ import {prefillAcceptedSettings} from './engineering-defaults.mjs';
1
5
  import {LocationChoices} from './LocationChoices.jsx';
2
6
  import {InlineNote} from './HelpNote.jsx';
3
7
  import {ProjectChecks} from './ProjectChecks.jsx';
@@ -99,6 +103,7 @@ import {
99
103
  Rocket,
100
104
  BrainCircuit,
101
105
  Waypoints,
106
+ Volume2,
102
107
  Mic,
103
108
  Square,
104
109
  Eraser,
@@ -165,6 +170,7 @@ import { zipSync, strToU8 } from "fflate";
165
170
  // Each browser starts fresh; earlier projects are opened deliberately (⋯ → Open project file).
166
171
  const STORAGE = "atforma-workspace-v3";
167
172
  // A new project starts empty: the user describes what they want first.
173
+ import './onboarding.css';
168
174
  const emptyProject = () => ({ id: crypto.randomUUID(), version: 1, revision: 1, name: "New project", provider: "tools", requirements: "", nodes: [], edges: [] });
169
175
  const ALL_CATALOG = [...CATALOG, ...UPSTREAM_CATALOG];
170
176
  const KIND_ICON = {
@@ -644,6 +650,7 @@ function harnessSignals(was, now) {
644
650
  }
645
651
  const welcome = (project) => ({
646
652
  role: "assistant",
653
+ introduction: true,
647
654
  text: project.nodes.length || project.brief?.application?.trim()
648
655
  ? `Good to see you again — let's keep going.\n\n${opening(project)}`
649
656
  : `What would you like to build?`,
@@ -663,7 +670,9 @@ document.documentElement.dataset.theme = savedTheme();
663
670
  function readChatView(id){try{const value=JSON.parse(localStorage.getItem('atmem-chat-view:'+id)||'null');return value&&Number.isSafeInteger(value.start)&&value.start>=0&&Array.isArray(value.turnIds)?value:{start:0,turnIds:[]};}catch{return {start:0,turnIds:[]};}}
664
671
  const pricingSignature=p=>JSON.stringify([p.id,p.estimate?.projectSize||'small',p.estimate?.usage,p.facts,p.decisions?.filter(d=>d.status==='Accepted'&&['cloud.region','data.country'].includes(d.subject)),[...storageNodes(p),...lambdaNodes(p),...googleNodes(p)].map(n=>[n.id,n.data.provider,n.data.service,n.data.config])]);
665
672
  function App() {
666
- const [project, setProject] = useState(readSaved);
673
+ const [starOffer,setStarOffer]=useState(0);
674
+ const [introOpen,setIntroOpen]=useState(false),[voiceOpenRequest,setVoiceOpenRequest]=useState(0);
675
+ const [project, setProject] = useState(()=>new URL(location.href).searchParams.get('example')==='1'&&!projectIdFromUrl(location.href)?readyExample():readSaved());
667
676
  const projectRef = useRef(project);
668
677
  projectRef.current = project;
669
678
  const [initialCanvasView] = useState(()=>readCanvasView(project));
@@ -695,7 +704,7 @@ function App() {
695
704
  [history, setHistory] = useState([]),
696
705
  [future, setFuture] = useState([]),
697
706
  [toast, setToast] = useState(""),
698
- [modal, setModal] = useState(() => (readConnection() ? null : "welcome")),
707
+ [modal, setModal] = useState(null),
699
708
  [menu, setMenu] = useState(false),
700
709
  [palette, setPalette] = useState(false),
701
710
  [issuesOpen, setIssuesOpen] = useState(false),
@@ -749,7 +758,8 @@ function App() {
749
758
  const pricingKey=pricingSignature(project);
750
759
  async function refreshEstimate(selected){
751
760
  if(!session?.token)return;
752
- const requested=selected.estimate?.projectSize?selected:setProjectSize(selected,'small');
761
+ const resolved=prefillAcceptedSettings(selected);
762
+ const requested=resolved.estimate?.projectSize?resolved:setProjectSize(resolved,'small');
753
763
  if(requested!==selected)updateEngagement(requested);
754
764
  const request=++pricingRequest.current;pricingAttempt.current.add(pricingSignature(requested));
755
765
  const baseline=JSON.stringify([requested.estimate?.prices,requested.estimate?.details]);
@@ -778,6 +788,12 @@ function App() {
778
788
  const consultationAttempts = useRef(new Set());
779
789
  const agentAbort = useRef(null);
780
790
  const messageInput = useRef(null);
791
+ const hasIntroduction=messages[0]?.role==='assistant'&&(messages[0].introduction===true||messages[0].text==='What would you like to build?'||messages[0].text.startsWith('Good to see you again'));
792
+ const idleChat=hasIntroduction&&!introOpen&&architectureChatEntries(project,messages).length===1;
793
+ useEffect(()=>{
794
+ const resize=()=>{const field=messageInput.current;if(field){field.style.height='32px';field.style.height=Math.min(120,Math.max(32,field.scrollHeight))+'px';}};
795
+ resize();window.addEventListener('resize',resize);return()=>window.removeEventListener('resize',resize);
796
+ },[input,chatWidth,chatOpen]);
781
797
  const intakeRef=useRef(restoreQueuedMessages(messages,project.id));
782
798
  const [intake,setIntake]=useState(intakeRef.current),[intakePaused,setIntakePausedState]=useState(()=>restoreIntakePaused(messages)||intakeRef.current.length>0);
783
799
  const [intakeIssue,setIntakeIssue]=useState(null);
@@ -1578,15 +1594,17 @@ function App() {
1578
1594
  const exportFiles = (files, suffix) => {
1579
1595
  download(zipSync(Object.fromEntries(Object.entries(files).map(([name, content]) => [name, typeof content === "string" ? strToU8(content) : content]))), `${slug}-r${project.revision}-${suffix}.zip`, "application/zip");
1580
1596
  };
1581
- const downloadBundle = bundle => {
1597
+ const downloadBundle = async bundle => {
1598
+ bundle=await(bundle.prepare?.()||bundle);
1599
+ if(bundle.project!==projectRef.current)throw Error('Design changed while preparing files. Download again.');
1582
1600
  download(zipSync(Object.fromEntries(Object.entries(bundle.files).map(([name,content])=>[name,typeof content === "string"?strToU8(content):content]))),bundle.filename,"application/zip");
1583
1601
  if(bundle.project?.id===projectRef.current.id)updateEngagement(acknowledgeStage(projectRef.current,'delivery'));
1584
- notify("ZIP downloaded.");
1602
+ notify("ZIP downloaded.");setStarOffer(value=>value+1);
1585
1603
  };
1586
1604
  const exportPack = async () => {
1587
1605
  try {
1588
1606
  notify("Preparing documents and diagram exports…");
1589
- downloadBundle(await outputBundle(project,run,environment));
1607
+ await downloadBundle(await outputBundle(project,run,environment));
1590
1608
  } catch(e) { notify(e.message); }
1591
1609
  };
1592
1610
  const exportTerraform = async (validation = null) => {
@@ -1661,6 +1679,8 @@ function App() {
1661
1679
  }
1662
1680
  }
1663
1681
  function switchTo(next) {
1682
+ setIntroOpen(false);setVoiceOpenRequest(0);
1683
+ const address=new URL(window.location.href);if(address.searchParams.get('project')!==next.id){address.searchParams.delete('project');address.searchParams.delete('example');window.history.replaceState(null,'',address);}
1664
1684
  chooseWorkspace('canvas');setBuildRequest(null);
1665
1685
  next = withDerived(next);
1666
1686
  const leaving = projectRef.current;
@@ -1719,8 +1739,14 @@ function App() {
1719
1739
  })();
1720
1740
  return () => { active = false; };
1721
1741
  }, [session?.token]);
1722
- function newProject() {
1723
- setModal("start-over");
1742
+ function newProject() { if(!busy&&!agentBusy&&!intake.length)setModal("start-over"); }
1743
+ function createOwnProject(){
1744
+ switchTo(validateImport(emptyProject()));setModal(connection?null:'connections');
1745
+ requestAnimationFrame(()=>document.querySelector('input[aria-label="Project name"]')?.focus());
1746
+ }
1747
+ function openReadyExample(){
1748
+ if(busy||agentBusy||intake.length)return;
1749
+ const example=readyExample();example.id=crypto.randomUUID();switchTo(example);setModal(null);
1724
1750
  }
1725
1751
  async function binProject(id) {
1726
1752
  try {
@@ -1834,7 +1860,7 @@ function App() {
1834
1860
  />
1835
1861
  );
1836
1862
  return (
1837
- <div style={{"--chat-panel-width":`${chatWidth}px`}} className={`app ${chatOpen&&!buildView ? "" : "chat-closed"} ${buildView?"build-workspace":""}`}>
1863
+ <div style={{"--chat-panel-width":`${chatWidth}px`}} className={`app ${idleChat?"idle-chat":""} ${chatOpen&&!buildView ? "" : "chat-closed"} ${buildView?"build-workspace":""}`}>
1838
1864
  <header className="bar">
1839
1865
  <button aria-label="About AtMem AI Architect" className="brand" onClick={() => setModal("about")} title="About AtMem AI Architect">
1840
1866
  <Mark />
@@ -1853,6 +1879,7 @@ function App() {
1853
1879
  /><PencilRuler size={14} aria-hidden="true"/></label>
1854
1880
  </nav>
1855
1881
  <div className="bar-actions">
1882
+ {isReadyExample(project)&&<><ReadyExampleDownload compact/><button className="btn" onClick={newProject} disabled={busy||agentBusy}>Create new project</button></>}
1856
1883
  {storageError && <span className="warn-text">Not saved</span>}
1857
1884
  <button
1858
1885
  className="icon"
@@ -1877,6 +1904,9 @@ function App() {
1877
1904
  {menu && (
1878
1905
  <div className="menu" role="menu" onClick={() => setMenu(false)}>
1879
1906
  <button role="menuitem" onClick={() => (refreshProjects(), setModal("projects"))} disabled={busy}><FolderOpen size={16}/>Projects</button>
1907
+ <a role="menuitem" href="https://atmem.ai" target="_blank" rel="noopener noreferrer">atmem.ai ↗</a>
1908
+ <a role="menuitem" href="mailto:hello@atmem.ai">Contact us: hello@atmem.ai</a>
1909
+ <button role="menuitem" onClick={openReadyExample} disabled={busy||agentBusy}><LayoutGrid size={16}/>Open complete AI example</button>
1880
1910
  <button role="menuitem" onClick={newProject} disabled={busy}><Plus size={16}/>New project</button>
1881
1911
  <hr/><button role="menuitem" onClick={() => setPalette(true)} disabled={busy}><Plus size={16}/>Add a block</button>
1882
1912
  <button role="menuitem" onClick={() => setModal("templates")} disabled={busy}><LayoutTemplate size={16}/>Templates</button>
@@ -1887,6 +1917,7 @@ function App() {
1887
1917
  <hr />
1888
1918
  <button role="menuitem" onClick={() => setModal("connections")}><Waypoints size={16}/>AI providers</button>
1889
1919
  <button role="menuitem" onClick={() => setModal("licenses")}><ShieldCheck size={16}/>Licences</button>
1920
+ <a role="menuitem" href={STAR_URL} target="_blank" rel="noopener noreferrer">Star on GitHub ↗</a>
1890
1921
  </div>
1891
1922
  )}
1892
1923
  </header>
@@ -1913,7 +1944,7 @@ function App() {
1913
1944
  {level!=="block"&&<aside className="board-components component-navigator" data-expanded={treeOpen} aria-label="Board components"><button className="btn" aria-label="Toggle components and layers" aria-expanded={treeOpen} onClick={()=>setTreeOpen(!treeOpen)}><LayoutGrid size={16}/> Components &amp; layers</button>
1914
1945
  {treeOpen&&<nav className="board-component-tree" aria-label="Component tree"><strong>{project.name}</strong><div className="tree-views"><button disabled={!blockReviews.length} onClick={()=>{reviewBlock((blockReviews.find(r=>r.status!=='reviewed')||blockReviews[0]).id);}}><CheckCheck size={16}/> Review blocks · {blockReviews.filter(r=>r.status==='reviewed').length}/{blockReviews.length}</button></div>{[...new Map(project.nodes.filter(n=>n.data.kind!=='note').map(n=>[layerOf(n).id,layerOf(n)])).values()].map(layer=><details open key={layer.id}><summary>{layer.label}</summary>{project.nodes.filter(n=>n.data.kind!=='note'&&layerOf(n).id===layer.id).map(n=><button key={n.id} aria-current={selected===n.id?'true':undefined} onClick={()=>{setMode('design');setDesignView('board');setFocus(null);setLevel('flow');setSelected(n.id);setTimeout(()=>fitView({nodes:[{id:n.id}],padding:1,maxZoom:1.2,duration:350}),100);}}><Database size={14}/><span>{n.data.label}<small>{n.data.service||n.data.provider}</small><small className={`tree-review-state ${blockReviews.find(r=>r.id===n.id)?.status}`}>{blockReviews.find(r=>r.id===n.id)?.statusLabel}</small></span></button>)}</details>)}</nav>}
1915
1946
  {treeOpen&&<button className="btn" disabled={busy} onClick={()=>setPalette(true)}><Plus size={16}/> Add component</button>}</aside>}
1916
- {!hasDesign&&!palette&&!dismissedStarts.includes(project.id)&&<section className="board-start" aria-label="Start your project"><button type="button" className="icon board-start-close" aria-label="Dismiss welcome card" title="Dismiss welcome card" onClick={dismissStart}><X size={16}/></button><h2>What would you like to create?</h2><div className="row"><button className="btn primary" onClick={()=>{dismissStart();setChatOpen(true);document.querySelector('.composer textarea, .chat textarea')?.focus();}}>Start chatting</button><button className="btn" onClick={()=>{setChatOpen(true);requirementsInput.current.click();}}>Upload requirements or diagram</button><button className="btn" onClick={()=>setModal("project-file")}>Open project</button></div><InlineNote label="Supported starting files">Word, PDF, text, Markdown, draw.io, images and Architect project JSON.</InlineNote></section>}
1947
+ {!hasDesign&&!palette&&!dismissedStarts.includes(project.id)&&<section className="board-start" aria-label="Start your project"><button type="button" className="icon board-start-close" aria-label="Dismiss welcome card" title="Dismiss welcome card" onClick={dismissStart}><X size={16}/></button><h2>What would you like to create?</h2><div className="row"><button className="btn primary" onClick={openReadyExample}>Explore complete AI example</button><button className="btn primary" onClick={()=>{dismissStart();setChatOpen(true);document.querySelector('.composer textarea, .chat textarea')?.focus();}}>Start chatting</button><button className="btn" onClick={()=>{setChatOpen(true);requirementsInput.current.click();}}>Upload requirements or diagram</button><button className="btn" onClick={()=>setModal("project-file")}>Open project</button></div><InlineNote label="Supported starting files">Word, PDF, text, Markdown, draw.io, images and Architect project JSON.</InlineNote></section>}
1917
1948
  <ReactFlow
1918
1949
  nodes={canvasNodes}
1919
1950
  edges={canvasEdges}
@@ -1979,8 +2010,8 @@ function App() {
1979
2010
  >
1980
2011
  <LayoutGrid size={17} />
1981
2012
  </button>
1982
- <button className="icon" title="Clear board" aria-label="Clear board" disabled={!project.nodes.length || busy} onClick={clearBoard}>
1983
- <Eraser size={17} />
2013
+ <button className="btn reset-board" title="Reset board and context" aria-label="Reset board and context" disabled={busy||agentBusy||intake.length>0} onClick={newProject}>
2014
+ <Trash2 size={17} /> Reset board
1984
2015
  </button>
1985
2016
  <button className="icon" title="Undo board change" aria-label="Undo" disabled={!history.length || busy || agentBusy} onClick={undo}>
1986
2017
  <Undo2 size={17} />
@@ -2026,8 +2057,8 @@ function App() {
2026
2057
  <Scan size={16}/> Select all
2027
2058
  </button>
2028
2059
  <div className="ctx-divider" role="separator"/>
2029
- <button role="menuitem" className="danger" disabled={!project.nodes.length} onClick={() => (clearBoard(), setCtx(null))}>
2030
- <Eraser size={16}/> Clear board
2060
+ <button role="menuitem" className="danger" disabled={!project.nodes.length} onClick={() => (newProject(), setCtx(null))}>
2061
+ <Trash2 size={16}/> Reset board and context
2031
2062
  </button>
2032
2063
  </>
2033
2064
  )}
@@ -2164,12 +2195,12 @@ function App() {
2164
2195
  <header className="review-intro"><h2>Review</h2></header>
2165
2196
  <div className="review-design-status"><ShieldCheck size={18}/><span>{findings.filter(f=>f.status==='open').length} design items</span><button className="icon" aria-label="Open design checks in specification" title="Specification" onClick={()=>visitJourney('scope')}><Code2 size={16}/></button></div>
2166
2197
  <section className="review-costs" aria-label="Project costs">
2167
- {["review","estimate"].includes(mode) && <EstimateView project={project} result={costs} onChange={updateEngagement} busy={busy||agentBusy||!!pending} onEstimate={refreshEstimate} pricing={pricingState} onNext={()=>{const section=document.querySelector('.review-generation');const target=section?.querySelector('button')||section;if(target){target.setAttribute('tabindex','-1');target.focus({preventScroll:true});target.scrollIntoView({block:'center'});}}} />}
2198
+ {["review","estimate"].includes(mode) && <EstimateView project={project} result={costs} onChange={updateEngagement} busy={busy||agentBusy||!!pending} onEstimate={refreshEstimate} onChooseRegion={()=>{visitJourney('cloud');requestAnimationFrame(()=>{const input=document.querySelector('.location-region-form input');input?.focus();input?.scrollIntoView({block:'center',behavior:'instant'});});}} pricing={pricingState} onNext={()=>{const section=document.querySelector('.review-generation');const target=section?.querySelector('button')||section;if(target){target.setAttribute('tabindex','-1');target.focus({preventScroll:true});target.scrollIntoView({block:'center'});}}} />}
2168
2199
  </section>
2169
2200
  {mode==='review'&&session?.engineeringEnabled&&<section className="review-generation" aria-label="Generation and verification"><EngineeringPanel key={project.id} project={project} onBusyChange={setGenerationPending} onWorkModelChange={changeArchitectureWork} onIntentContext={syncEngineeringIntent} projectId={project.id} session={session} connection={connection} contextBusy={busy||agentBusy||!!pending} saveProject={()=>saveEngineeringProject(project.id)} buildRequest={buildRequest} onBuildRequestHandled={id=>setBuildRequest(current=>current?.id===id?null:current)} onConnections={()=>setModal('connections')}/></section>}
2170
2201
  {mode==='review'&&!session?.engineeringEnabled&&<p>Generation and local checks require the engineering service. Your design is retained; Delivery can package the design and remaining tasks.</p>}
2171
- {mode==='review'&&<ProjectChecks receipt={checkReceipt} project={project} environment={environment} session={session} onReceipt={setCheckReceipt} onBusy={setChecking}/>}
2172
- {mode==='review'&&<footer className="review-finish"><div><strong>3. Package and download</strong><p>Explore all files and download one project ZIP in Delivery.</p></div><button className="btn primary" onClick={()=>{const next=acknowledgeStage(projectRef.current,'review');updateEngagement(costs.complete?{...next,extensions:{...next.extensions,journey:{...next.extensions.journey,estimate:journey(next).keys.estimate}}}:next);visitJourney('delivery');}}>Complete →</button></footer>}
2202
+ {mode==='review'&&<ProjectChecks generationPending={generationPending} receipt={checkReceipt} project={project} environment={environment} session={session} onReceipt={setCheckReceipt} onBusy={setChecking}/>}
2203
+ {mode==='review'&&<footer className="review-finish"><div><strong>3. Package and download</strong><p>Explore all files and download one project ZIP in Delivery.</p></div><button className="btn primary" onClick={()=>{const next=acknowledgeStage(projectRef.current,'review');updateEngagement(costs.expected!==null&&!costs.mixedCurrencies?{...next,extensions:{...next.extensions,journey:{...next.extensions.journey,estimate:journey(next).keys.estimate}}}:next);visitJourney('delivery');}}>Complete →</button></footer>}
2173
2204
  </div>
2174
2205
 
2175
2206
 
@@ -2177,7 +2208,7 @@ function App() {
2177
2208
 
2178
2209
  {onBoard&&emulationOpen&&<div className="emulation-dock"><EmulationPanel project={project} session={session} emulation={emulation} onDiscuss={discussEmulation} onOpenComponent={id=>{reviewBlock(id);setEmulationOpen(false);}} discussionBusy={busy||agentBusy||!!pending} onClose={()=>setEmulationOpen(false)} onBuild={session?.engineeringEnabled?()=>{setEmulationOpen(false);buildCurrentDesign('test');}:undefined}/></div>}
2179
2210
 
2180
- {mode === "design" && designView === "outputs" && <OutputsView onNavigate={target=>{const [stage,selector]={requirements:['scope','.requirements-document'],location:['cloud','section[aria-label="Country and region"]'],cost:['review','.review-costs'],implementation:['review','.review-generation'],checks:['review','.project-checks']}[target];visitJourney(stage);requestAnimationFrame(()=>requestAnimationFrame(()=>{const element=document.querySelector(selector);if(element){element.setAttribute('tabindex','-1');element.focus({preventScroll:true});element.scrollIntoView({block:'start'});}}));}} checkReceipt={checkReceipt} onDownloaded={()=>updateEngagement(acknowledgeStage(projectRef.current,'delivery'))} onReview={()=>visitJourney('review')} onSave={expected=>saveEngineeringProject(project.id,expected)} project={project} run={run} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDownload={downloadBundle} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2211
+ {mode === "design" && designView === "outputs" && <OutputsView onNavigate={async target=>{const [stage,selector]={requirements:['scope','.requirements-document'],location:['cloud','section[aria-label="Country and region"]'],cost:['review','.review-costs'],implementation:['review','.review-generation'],checks:['review','.project-checks']}[target];visitJourney(stage);await new Promise((resolve,reject)=>{const started=performance.now();function focus(){const element=document.querySelector(selector);if(element&&element.getClientRects().length){element.setAttribute('tabindex','-1');element.focus({preventScroll:true});element.scrollIntoView({block:'start',behavior:'instant'});resolve();}else if(performance.now()-started<5000)requestAnimationFrame(focus);else reject(Error('Section is unavailable. Use the stage tabs to continue.'));}requestAnimationFrame(focus);});}} checkReceipt={checkReceipt} onDownloaded={()=>{updateEngagement(acknowledgeStage(projectRef.current,'delivery'));setStarOffer(value=>value+1);}} onReview={()=>visitJourney('review')} onSave={expected=>saveEngineeringProject(project.id,expected)} project={project} run={run} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDownload={downloadBundle} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2181
2212
  {mode === "design" && designView === "terraform" && <TerraformView project={project} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDelivery={()=>visitJourney("delivery")} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2182
2213
  <div className="choices-workspace" hidden={!(mode==="design"&&["options","decisions","location"].includes(designView))}>
2183
2214
  {mode === "design" && ["options","decisions"].includes(designView) && (
@@ -2192,7 +2223,7 @@ function App() {
2192
2223
 
2193
2224
  {!chatOpen&&!buildView && <button className="icon chat-reopen" title="Show chat" aria-label="Show chat" onClick={()=>setChatOpen(true)}><PanelRightOpen size={18}/></button>}
2194
2225
  {chatOpen&&!buildView && (
2195
- <aside className="chat" aria-label="Chat"><ChatResizeHandle width={chatWidth} onChange={resizeChat}/>
2226
+ <aside className={`chat ${idleChat?"idle":""}`} aria-label="Chat"><ChatResizeHandle width={chatWidth} onChange={resizeChat}/>
2196
2227
  <div className="chat-head">
2197
2228
  <div className="chat-heading-row"><strong className="chat-title" aria-label="AI Architect">
2198
2229
  <Mark size={24} />
@@ -2214,6 +2245,10 @@ function App() {
2214
2245
  <span className={`led ${connection && session ? "on" : ""}`} />
2215
2246
  {connection ? `${connection.model}${connection.effort ? ` · ${EFFORT_LABEL[connection.effort] || connection.effort}` : ""}` : "Connect a model"}
2216
2247
  </button>
2248
+ <span className="chat-top-utilities">
2249
+ {hasIntroduction&&<button type="button" aria-expanded={introOpen} onClick={()=>setIntroOpen(value=>!value)}>{introOpen?'▾':'▸'} Introduction</button>}
2250
+ {session&&<button type="button" className="icon" aria-label="Voice settings" title="Voice settings" onClick={()=>setVoiceOpenRequest(value=>value+1)}><Volume2 size={15}/></button>}
2251
+ </span>
2217
2252
  </div>
2218
2253
  <div className="skills" aria-label="Active skills">
2219
2254
  <button className="known-bar" disabled={!session} aria-label="AtMem memory" title="Open AtMem memory, confirmed decisions and sync activity" onClick={()=>setModal("memory")}><BrainActivity size={18} active={false}/>{memoryLabel}<ChevronRight size={14}/></button>
@@ -2263,6 +2298,7 @@ function App() {
2263
2298
  if(entry.kind==='activity-error')return <p key="activity-error" role="alert">{entry.error}</p>;
2264
2299
  if(entry.kind==='legacy-activity')return <EarlierArchitectureActivity key={entry.id} entries={entry.entries} projectId={project.id} session={session}/>;
2265
2300
  if(entry.kind==='activity')return <ArchitectureTurn key={project.id+':'+entry.turn.id} turn={entry.turn} number={entry.number} projectId={project.id} session={session} active={agentBusy&&entry.turn.clientStatus==='submitted'&&entry.turn.id===latestArchitectureTurn?.id} working={agentBusy&&entry.turn.clientStatus==='submitted'} onStop={()=>agentAbort.current?.abort()}/>;
2301
+ if(entry.index===0&&hasIntroduction&&!introOpen)return null;
2266
2302
  const m=entry.message,i=entry.index,turn=project.extensions?.architectureActivity?.turns?.find(t=>t.id===m.activityId),messageTime=m.createdAt||(m.role==='assistant'?turn?.completedAt:null)||turn?.startedAt;
2267
2303
  return (
2268
2304
  <div key={i} className={`msg ${m.role}`}>
@@ -2324,7 +2360,7 @@ function App() {
2324
2360
  </button>
2325
2361
  ))}
2326
2362
  </div>
2327
- {(agentBusy||generationPending||['starting','running'].includes(engineeringStatus?.state)||pricingState.busy||checking)&&<div className="chat-working-status" role="status" aria-live="polite"><BrainActivity size={26}/><span>{agentBusy?architectAction:generationPending?'Starting project generation…':['starting','running'].includes(engineeringStatus?.state)?'Generating project files…':checking?'Running checks…':'Calculating costs…'}</span></div>}
2363
+ {(agentBusy||generationPending||['starting','running'].includes(engineeringStatus?.state)||pricingState.busy||checking)&&<div className="chat-working-status" role="status" aria-live="polite"><BrainActivity size={26}/><span>{agentBusy?architectAction:generationPending?'Generating project files…':['starting','running'].includes(engineeringStatus?.state)?'Generating project files…':checking?'Running checks…':'Calculating costs…'}</span></div>}
2328
2364
  {readingAttachments&&<p className="attachment-status" role="status"><BrainActivity/> Reading files…</p>}
2329
2365
  {attachmentError&&<p className="attachment-status" role="alert">{attachmentError}</p>}
2330
2366
  {!!attachments.length&&<div className="attachment-tray" aria-label="Attachments ready to send">{attachments.map(a=><div className="attachment-chip" key={a.id}>{a.data&&a.mime.startsWith('image/')?<img src={`data:${a.mime};base64,${a.data}`} alt=""/>:<Paperclip size={16}/>}<span>{a.name}<small>{!a.data?'Original missing · attach again':a.mime.startsWith('image/')?'Image':a.mime==='application/pdf'||a.mime.includes('wordprocessingml')?'Text will be extracted; embedded images omitted':'Document text'}</small></span><button className="icon" aria-label={`Remove ${a.name}`} onClick={()=>setAttachments(list=>list.filter(item=>item.id!==a.id))}><X size={14}/></button></div>)}<p>Sent to your selected architect with this message. Original files are not stored in project files.</p></div>}
@@ -2369,7 +2405,7 @@ function App() {
2369
2405
  </button>
2370
2406
  </div>
2371
2407
  </form>
2372
- {session&&<OpenAIVoicePanel key={project.id} session={session} connection={connection} transportRef={openaiVoice} onConnections={()=>setModal('connections')} onStart={()=>{voice.current?.cancel();setVoiceError('');}} onTranscript={(text,automatic)=>{
2408
+ {session&&<OpenAIVoicePanel hideTrigger openRequest={voiceOpenRequest} key={project.id} session={session} connection={connection} transportRef={openaiVoice} onConnections={()=>setModal('connections')} onStart={()=>{voice.current?.cancel();setVoiceError('');}} onTranscript={(text,automatic)=>{
2373
2409
  if(automatic&&!input.trim()&&!attachments.length){setInput(text);void send(text);}
2374
2410
  else setInput(current=>current+(current.trim()?' ':'')+text);
2375
2411
  }}/>}
@@ -2387,6 +2423,7 @@ function App() {
2387
2423
  {toast === "Preparing documents and diagram exports…"&&<BrainActivity/>}{toast}
2388
2424
  </div>
2389
2425
  )}
2426
+ <StarPrompt offer={starOffer}/>
2390
2427
  {modal && (
2391
2428
  <div className="backdrop" onMouseDown={(e) => e.target === e.currentTarget && !(modal === "welcome" && !connection) && setModal(null)}>
2392
2429
  <section className={`modal ${modal==="memory"?"memory-modal":""}`} role="dialog" aria-modal="true" aria-label={modal}>
@@ -2421,22 +2458,20 @@ function App() {
2421
2458
  /></div>
2422
2459
  ) : modal === "start-over" ? (
2423
2460
  <>
2424
- <h2>Start over?</h2>
2425
- <p>Start a new idea with an empty board and chat. Requirements, decisions and estimates start fresh too. Your model connection stays.</p>
2461
+ <h2>Reset board and context?</h2>
2462
+ <p>Start fresh with an empty board, chat, requirements and decisions. Your saved projects and model connection stay.</p>
2426
2463
  <div className="reset-actions">
2427
2464
  <button className="btn primary" title="Download your project, then start a new one" aria-label="Save project and start over" onClick={() => {
2428
2465
  if (!saveProjectFile()) return;
2429
- switchTo(validateImport(emptyProject()));
2430
- setModal(null);
2466
+ createOwnProject();
2431
2467
  }}><Package size={18}/> Save & start new</button>
2432
2468
  <button
2433
- className="btn" title="Start a new project without downloading this one" aria-label="Start over without download"
2469
+ className="btn" title="Start a new project without downloading this one" aria-label="Confirm reset board and context"
2434
2470
  onClick={() => {
2435
- switchTo(validateImport(emptyProject()));
2436
- setModal(null);
2471
+ createOwnProject();
2437
2472
  }}
2438
2473
  >
2439
- <Plus size={18}/> Start without saving
2474
+ <Trash2 size={18}/> Reset board and context
2440
2475
  </button>
2441
2476
  <button className="btn ghost" onClick={() => setModal(null)}>
2442
2477
  <ArrowLeft size={18}/> Keep working
@@ -0,0 +1,10 @@
1
+ @media(max-width:900px){html,body,#root{height:100%;overflow:hidden}.app{height:100dvh;min-height:0;grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto minmax(100px,1fr) minmax(300px,50dvh);overflow:hidden}.app.chat-closed,.app.build-workspace{grid-template-rows:auto auto minmax(0,1fr)}.app>.canvas{min-height:0;height:auto;overflow:hidden}.app>.chat{height:auto;min-height:0;overflow:hidden}.chat>.messages{min-height:0;flex:1;overflow:auto}.chat>.composer{position:relative;flex-shrink:0;background:var(--paper)}}
2
+ .chat>.composer{padding:6px 8px;border-radius:14px}.chat>.composer textarea{min-height:32px;height:32px;max-height:120px;padding:4px 6px;font-size:14px;line-height:20px;resize:none;overflow-y:auto}.chat>.composer .composer-actions{padding-top:2px}.chat>.composer .composer-actions button{width:32px;height:32px;min-height:32px;padding:6px}
3
+ .chat>.chat-head{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:4px 6px;padding:4px 8px;min-height:0}.chat-head>.chat-heading-row{display:contents}.chat-head .chat-title{display:none}.chat-head>.model{order:0;max-width:100%;font-size:12px;line-height:18px;min-height:26px;padding:3px 7px;margin:0}.chat-head>.live-toggle{order:1;flex:0 0 auto;width:auto;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-size:11px;line-height:18px;padding:0;margin:0}.chat-head .live-toggle input{width:13px;height:13px;margin:0}.chat-head .icon{order:2;width:26px;height:26px;min-height:26px;padding:4px;margin:0}.chat-head .icon svg{width:15px;height:15px}.chat>.skills{display:flex;align-items:center;flex-wrap:nowrap;overflow-x:auto;gap:3px;padding:2px 8px 4px;margin:0;min-height:0;background:transparent;border-radius:0}.chat>.skills .known-bar,.chat>.skills .skill{flex:0 0 auto;width:auto;font-size:11px;line-height:16px;min-height:24px;padding:3px 6px;margin:0;gap:4px;border-radius:7px}.chat>.skills svg{width:13px;height:13px}
4
+ .chat-head .chat-top-utilities{display:inline-flex;align-items:center;gap:4px;order:3;margin-left:auto}
5
+ .chat-head .chat-top-utilities button{font-size:11px;line-height:18px;min-height:26px;padding:3px 6px;border:0;background:transparent;color:var(--ink-2)}
6
+ .chat>.voice-status:empty{display:none}
7
+ .chat.idle>.messages{display:none}
8
+ @media(max-width:900px){.app.idle-chat{grid-template-rows:auto auto minmax(100px,1fr) auto}.app>.chat.idle{height:auto;min-height:0}}
9
+ .ready-reference{margin:20px 0}.ready-reference>p{margin:8px 0}.ready-reference li{display:flex;align-items:center;gap:10px;padding:8px 0}.ready-reference li>span:nth-child(2){flex:1}.ready-reference details{margin-top:10px}.ready-reference details p{margin:8px 0}
10
+ .bar-actions .ready-download{font-size:12px;padding:6px 10px}.toolbar .reset-board{font-size:12px;padding:6px 9px;gap:5px}
@@ -1,13 +1,25 @@
1
1
  import {verifiedReceipt} from './validation-receipt.mjs';
2
2
  import {deliveryPack} from './delivery.mjs';
3
- export async function outputBundle(project,run,environment,checks){
3
+ export const VISUAL_PATHS=['spec-kit/implementation-spec.pdf','specification.pdf','diagram.svg','diagram.png','diagram.pdf'];
4
+ export async function outputBundle(project,run,environment,checks,{deferVisuals=false}={}){
4
5
  const snapshot=structuredClone(project);
5
6
  const files=deliveryPack(snapshot,run,environment);
6
- const {visualFiles}=await import('./visual-export.mjs');
7
- Object.assign(files,await visualFiles(snapshot,files['specification.md'],files['spec-kit/implementation-spec.md']));
8
7
  if(checks){const terraform=Object.fromEntries(Object.entries(files).filter(([name])=>name.startsWith('terraform/')).map(([name,value])=>[name.slice(10),value]));const receipt=await verifiedReceipt(terraform,checks);if(receipt)files['terraform/validation-receipt.json']=JSON.stringify(receipt,null,2);}
9
8
  const manifest=JSON.parse(files['manifest.json']);manifest.files=Object.keys(files);
10
9
  files['manifest.json']=JSON.stringify(manifest,null,2);
11
10
  const slug=snapshot.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'atflow';
12
- return {project,files,manifest,filename:`${slug}-r${snapshot.revision}-delivery.zip`};
11
+ const base={project,files,manifest,filename:`${slug}-r${snapshot.revision}-delivery.zip`,visualPaths:VISUAL_PATHS};
12
+ let pending;
13
+ async function prepare(){
14
+ if(!pending)pending=(async()=>{
15
+ const {visualFiles}=await import('./visual-export.mjs');
16
+ const fullFiles={...files,...await visualFiles(snapshot,files['specification.md'],files['spec-kit/implementation-spec.md'])};
17
+ const fullManifest={...manifest,files:Object.keys(fullFiles)};
18
+ fullFiles['manifest.json']=JSON.stringify(fullManifest,null,2);
19
+ const full={...base,files:fullFiles,manifest:fullManifest,visualPaths:[]};
20
+ return {...full,prepare:()=>Promise.resolve(full)};
21
+ })().catch(error=>{pending=null;throw error;});
22
+ return pending;
23
+ }
24
+ return deferVisuals?{...base,prepare}:prepare();
13
25
  }
package/src/pricing.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import {prefillAcceptedSettings} from './engineering-defaults.mjs';
1
2
  import { setPrice, usage } from './estimate.mjs';
2
3
  const patterns = {aws:/\bs3\b/i,azure:/blob|data lake|azure storage/i,gcp:/cloud storage|storage bucket/i,ibm:/object storage|\bcos\b/i};
3
4
  export const storageNodes = p => p.nodes.filter(n=>patterns[n.data.provider]?.test(`${n.data.label} ${n.data.service || ''}`));
4
- const sources = {'AWS public Price List':'https://pricing.us-east-1.amazonaws.com/','Azure Retail Prices API':'https://prices.azure.com/','Google Cloud Billing Catalog':'https://cloud.google.com/'};
5
+ const sources = {'AWS public Price List':'https://pricing.us-east-1.amazonaws.com/','Azure Retail Prices API':'https://prices.azure.com/','Google Cloud Billing Catalog':'https://cloud.google.com/','Google Cloud public pricing':'https://cloud.google.com/storage/pricing'};
5
6
  export function applyPriceQuotes(project, quotes) {
6
7
  let p=project;
7
8
  for(const quote of quotes||[]){
@@ -11,7 +12,7 @@ export function applyPriceQuotes(project, quotes) {
11
12
  if(n.data.provider!==quote.provider || n.data.config?.region!==quote.region || existing?.source && !sources[existing.source])continue;
12
13
  // Shared usage is decimal GB; preserve the conversion in the quote note.
13
14
  const factor=quote.unit==='GiB-month'?1e9/(1024**3):1;
14
- p=setPrice(p,n.id,{price:quote.price*factor,currency:quote.currency,source:quote.source,url:quote.url,date:quote.date,region:quote.region,service:quote.service,tierLimit:quote.tierLimit,coverageIncomplete:true,note:quote.coverage+(factor!==1?`; provider GiB converted from decimal GB using ${factor}`:'')});
15
+ p=setPrice(p,n.id,{price:quote.price*factor,currency:quote.currency,source:quote.source,url:quote.url,date:quote.date,region:quote.region,service:quote.service,tierLimit:quote.tierLimit,coverageIncomplete:true,sourceSha256:quote.sourceSha256,hourlyPrice:quote.hourlyPrice,hoursPerMonth:quote.hoursPerMonth,note:quote.coverage+(factor!==1?`; provider GiB converted from decimal GB using ${factor}`:'')});
15
16
  }
16
17
  }
17
18
  return p;
@@ -49,9 +50,10 @@ export function applyLambdaQuote(project,node,quote){
49
50
  return {...project,estimate:{...project.estimate,details:[...current.filter(line=>!line.id.startsWith(prefix)),...generated]}};
50
51
  }
51
52
  export async function fetchPlanningPrices(session,project){
53
+ project=prefillAcceptedSettings(project);
52
54
  const storage=await fetchStoragePrices(session,project);let next=applyPriceQuotes(project,storage.quotes);const errors=[...storage.errors];
53
55
  for(const node of lambdaNodes(project)){
54
- const region=node.data.config?.region;if(!region||!/^[a-z]{2}-(?:gov-)?[a-z]+-\d$/.test(region)){errors.push({component:node.data.label,reason:'Choose the AWS region in Choices to price this component.'});continue;}
56
+ const region=node.data.config?.region;if(!region||!/^[a-z]{2}-(?:gov-)?[a-z]+-\d$/.test(region)){errors.push({provider:'aws',action:'choose-region',reason:'Choose an AWS region in Choices.'});continue;}
55
57
  try{
56
58
  const architecture=node.data.config?.architecture==='arm64'?'arm64':'x86_64';
57
59
  const response=await fetch('/api/pricing/lambda',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({projectId:project.id,region,architecture}),signal:AbortSignal.timeout(20000)}),quote=await response.json();
@@ -63,7 +65,7 @@ export async function fetchPlanningPrices(session,project){
63
65
  const region=node.data.config?.region||'',service=/cloud run/i.test(`${node.data.service||''} ${node.data.label}`)?'cloud-run':'cloud-sql';
64
66
  try{const response=await fetch('/api/pricing/google',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({projectId:project.id,region,service,planningReference:!region||/select|unknown|tbd/i.test(region)}),signal:AbortSignal.timeout(20000)}),quote=await response.json();if(!response.ok)throw Error(quote.detail||'Google pricing could not be verified.');next=applyGoogleQuote(next,node,quote);}catch(error){errors.push({component:node.data.label,reason:error.message});}
65
67
  }
66
- return {project:next,errors};
68
+ return {project:next,errors:[...new Map(errors.map(error=>[`${error.provider||''}:${error.reason}:${error.action||''}`,error])).values()]};
67
69
  }
68
70
 
69
71
  export const googleNodes=p=>p.nodes.filter(n=>n.data.provider==='gcp'&&n.data.origin!=='existing'&&/cloud run|cloud sql/i.test(`${n.data.service||''} ${n.data.label}`));