| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>VLCode Lite</title>
- <link id="dynFavicon" rel="icon" type="image/png" sizes="32x32" href="/assets/vlcode-lite-favicon-32.png?v=20260315">
- <link rel="icon" type="image/png" sizes="16x16" href="/assets/vlcode-lite-favicon-16.png?v=20260315">
- <link rel="icon" type="image/svg+xml" href="/assets/vlcode-lite-icon.svg?v=20260315">
- <!-- CodeMirror 5 (served locally from node_modules) -->
- <link rel="stylesheet" href="/lib/codemirror/lib/codemirror.css">
- <link rel="stylesheet" href="/lib/codemirror/addon/fold/foldgutter.css">
- <script src="/lib/codemirror/lib/codemirror.js"></script>
- <script src="/lib/codemirror/addon/edit/matchbrackets.js"></script>
- <script src="/lib/codemirror/addon/edit/closebrackets.js"></script>
- <script src="/lib/codemirror/addon/selection/active-line.js"></script>
- <script src="/lib/codemirror/addon/fold/foldcode.js"></script>
- <script src="/lib/codemirror/addon/fold/foldgutter.js"></script>
- <script src="/lib/codemirror/addon/fold/indent-fold.js"></script>
- <script src="/lib/codemirror/mode/javascript/javascript.js"></script>
- <script src="/lib/codemirror/mode/css/css.js"></script>
- <script src="/lib/codemirror/mode/xml/xml.js"></script>
- <script src="/lib/codemirror/mode/htmlmixed/htmlmixed.js"></script>
- <style>
- :root {
- --bg: #0d1117; --bg1: #0a0f14; --bg2: #161b22; --bg3: #21262d; --hover: #1b2129; --border: #30363d;
- --text: #e6edf3; --text2: #8b949e; --accent: #58a6ff; --accent-rgb: 88, 166, 255; --green: #3fb950;
- --yellow: #d29922; --orange: #f0883e; --red: #f85149; --purple: #a371f7; --blue: #79c0ff; --cyan: #5ccfe6;
- --font: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
- }
- * { margin:0; padding:0; box-sizing:border-box; }
- body { background:var(--bg); color:var(--text); font-family:var(--font); font-size:13px; height:100vh; display:flex; flex-direction:column; }
- /* Header */
- header { background:linear-gradient(180deg, #151b23 0%, #111720 100%); border-bottom:1px solid var(--border); padding:4px 10px; display:flex; align-items:center; gap:8px; height:32px; z-index:110; position:relative; }
- body.desktop-app header { -webkit-app-region:drag; padding-left:84px; }
- body.desktop-app header button,
- body.desktop-app header input,
- body.desktop-app header select,
- body.desktop-app header textarea,
- body.desktop-app header .ws-current,
- body.desktop-app header .ws-popover,
- body.desktop-app header .wf-selector,
- body.desktop-app header .auth-status,
- body.desktop-app header .llm-badge,
- body.desktop-app header .ctx-bar { -webkit-app-region:no-drag; }
- header h1 { font-size:13px; color:var(--accent); font-weight:600; white-space:nowrap; }
- header .info { color:var(--text2); font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- header .spacer { flex:1; }
- header .ctx-bar { display:flex; align-items:center; gap:6px; }
- header .ctx-bar .bar { width:100px; height:5px; background:var(--bg3); border-radius:3px; overflow:hidden; }
- header .ctx-bar .bar-fill { height:100%; background:var(--green); border-radius:3px; transition:width 0.3s; }
- .hdr-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:3px 8px; border-radius:4px; cursor:pointer; font-family:var(--font); font-size:10px; white-space:nowrap; }
- .hdr-btn:hover { background:var(--border); color:var(--text); }
- .hdr-btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
- #compileBtn { background:rgba(63,185,80,0.15); color:#a8f0b2; border-color:rgba(63,185,80,0.35); }
- #compileBtn:hover { background:rgba(63,185,80,0.22); color:#fff; border-color:rgba(63,185,80,0.55); }
- /* Mode toggle switch */
- .mode-toggle { display:inline-flex; align-items:center; cursor:pointer; vertical-align:middle; }
- .mode-toggle-track { position:relative; width:80px; height:26px; border-radius:13px; background:var(--accent); border:1px solid var(--accent); transition:background .2s, border-color .2s; display:flex; align-items:center; justify-content:space-between; padding:0 8px; user-select:none; }
- .mode-toggle-track.human { background:#2ea043; border-color:#2ea043; }
- .mode-toggle-label { font-size:10px; font-weight:600; font-family:var(--font); color:rgba(255,255,255,.5); z-index:1; transition:color .2s; }
- .mode-toggle-track .mode-label-human { color:rgba(255,255,255,.4); }
- .mode-toggle-track .mode-label-ai { color:#fff; }
- .mode-toggle-track.human .mode-label-human { color:#fff; }
- .mode-toggle-track.human .mode-label-ai { color:rgba(255,255,255,.4); }
- .mode-toggle-thumb { position:absolute; top:2px; right:2px; width:20px; height:20px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.3); transition:right .2s, left .2s; }
- .mode-toggle-track.human .mode-toggle-thumb { right:auto; left:2px; }
- .hdr-btn-primary:hover { background:#79b8ff; }
- main { flex:1; display:flex; overflow:hidden; }
- /* File sidebar */
- .sidebar { width:220px; background:var(--bg2); border-right:1px solid var(--border); display:flex; flex-direction:column; position:relative; }
- .sidebar h3 { padding:10px 12px; font-size:11px; color:var(--text2); text-transform:uppercase; letter-spacing:1px; }
- .file-tree { flex:1; overflow-y:auto; padding:0 6px 6px; }
- .file-tree .category { margin-bottom:6px; }
- .file-tree .cat-name { color:var(--accent); font-size:10px; padding:3px 8px; cursor:pointer; }
- .file-tree .file { padding:2px 8px 2px 12px; cursor:pointer; color:var(--text2); border-radius:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:12px; }
- .file-tree .file:hover { background:var(--bg3); color:var(--text); }
- .file-tree .file.active { background:var(--accent); color:#fff; }
- /* Workflow selector dropdown */
- .wf-selector { position:relative; }
- .wf-dropdown { display:none; position:absolute; top:100%; right:0; margin-top:4px; background:var(--bg2); border:1px solid var(--border); border-radius:6px; min-width:240px; z-index:50; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
- .wf-dropdown.open { display:block; }
- .wf-item { padding:6px 10px; cursor:pointer; font-size:11px; display:flex; align-items:center; justify-content:space-between; }
- .wf-item:hover { background:var(--bg3); }
- .wf-item .wf-name { color:var(--text); }
- .wf-item .wf-steps { color:var(--text2); font-size:9px; }
- .wf-item .wf-view { color:var(--accent); font-size:9px; cursor:pointer; margin-left:6px; }
- .wf-item .wf-view:hover { text-decoration:underline; }
- /* Sidebar actions */
- .sidebar-actions { display:flex; gap:3px; padding:0 10px 6px; flex-wrap:wrap; }
- .sidebar-actions .sa-btn { display:inline-flex; align-items:center; gap:3px; background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:3px 7px; border-radius:4px; cursor:pointer; font-family:var(--font); font-size:9px; text-align:center; white-space:nowrap; }
- .sidebar-actions .sa-btn:hover { background:var(--border); color:var(--text); }
- .sidebar-actions .sa-btn.active { background:rgba(88,166,255,0.12); color:var(--accent); border-color:rgba(88,166,255,0.35); }
- .sidebar-actions .sa-btn.sa-danger:hover { background:var(--red); color:#fff; border-color:var(--red); }
- .sidebar-actions .sa-btn .sa-icon { font-size:10px; }
- /* File tree context menu */
- .ctx-menu { display:none; position:fixed; z-index:200; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:4px 0; min-width:140px; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
- .ctx-menu.open { display:block; }
- .ctx-menu-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); }
- .ctx-menu-item:hover { background:var(--bg3); color:var(--text); }
- .ctx-menu-item.danger { color:var(--red); }
- .ctx-menu-item.danger:hover { background:var(--red); color:#fff; }
- .ctx-menu-sep { height:1px; background:var(--border); margin:3px 0; }
- /* File icons — VS Code / Claude Code inspired */
- .file-icon { display:inline-block; width:14px; text-align:center; margin-right:4px; font-size:11px; flex-shrink:0; }
- .type-badge { display:inline-block; font-size:8px; padding:1px 3px; border-radius:2px; margin-right:3px; font-weight:600; }
- .type-app { background:#1f6feb33; color:var(--accent); }
- .type-section { background:#3fb95033; color:var(--green); }
- .type-component { background:#d2992233; color:var(--yellow); }
- .type-service { background:#f8514933; color:var(--red); }
- .type-database { background:#8b949e33; color:var(--text2); }
- .type-theme { background:#a371f733; color:var(--purple); }
- .type-process { background:#a371f720; color:#c49bff; }
- .type-json { background:#d2992220; color:#e0ad40; }
- .type-doc { background:#8b949e20; color:#9da5ae; }
- .type-image { background:#f0883e20; color:#f0883e; }
- .type-report { background:#3fb95020; color:#3fb950; }
- .type-log { background:#8b949e20; color:#8b949e; }
- .type-config { background:#d2992220; color:#d29922; }
- .type-workflow { background:#a371f720; color:#a371f7; }
- /* Mode tabs (Code / Map / Flow) */
- .mode-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); padding:0 8px; gap:2px; }
- .mode-tab { padding:6px 16px; cursor:pointer; color:var(--text2); font-size:11px; font-weight:600; border-bottom:2px solid transparent; border-radius:4px 4px 0 0; transition:all 0.15s; white-space:nowrap; }
- .mode-tab:hover { color:var(--text); background:var(--bg3); }
- .mode-tab.active { color:var(--accent); border-bottom-color:var(--accent); background:var(--bg); }
- /* Folder path link */
- .folder-path { padding:4px 12px; font-size:9px; color:var(--text2); cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:flex; align-items:center; gap:4px; border-bottom:1px solid var(--border); }
- .folder-path:hover { color:var(--accent); background:var(--bg3); }
- .folder-path .fp-icon { font-size:10px; flex-shrink:0; }
- /* Workflow binding labels */
- .wf-binding { display:flex; align-items:center; justify-content:space-between; padding:5px 10px; font-size:10px; border-bottom:1px solid var(--border); }
- .wf-binding .wf-b-label { color:var(--text2); }
- .wf-binding select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 4px; font-family:var(--font); font-size:9px; outline:none; max-width:140px; }
- /* Auto-linked URLs */
- .msg.assistant .content-text a { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
- .msg.assistant .content-text a:hover { color:#79b8ff; }
- /* Preview URL list in sidebar */
- .project-config { border-top:1px solid var(--border); }
- .pc-header { font-size:9px; color:var(--text2); padding:6px 12px; cursor:pointer; text-transform:uppercase; letter-spacing:0.5px; margin:0; }
- .pc-header:hover { color:var(--text); }
- .pc-file { padding:3px 12px 3px 18px; font-size:11px; cursor:pointer; color:var(--yellow); border-radius:4px; display:flex; align-items:center; justify-content:space-between; }
- .pc-file:hover { background:var(--bg3); color:var(--text); }
- .pc-sync-btn { background:none; border:1px solid var(--border); color:var(--text2); border-radius:3px; cursor:pointer; font-size:11px; padding:0 4px; line-height:1.4; }
- .pc-sync-btn:hover { color:var(--accent); border-color:var(--accent); }
- .doc-id-panel-note { padding:6px 12px 2px; font-size:9px; color:var(--text2); line-height:1.5; }
- .doc-id-panel-actions { display:flex; gap:6px; padding:6px 12px 4px; }
- .doc-id-panel-actions .pc-sync-btn { flex:1; padding:2px 0; }
- .doc-id-grid { display:flex; flex-direction:column; gap:6px; padding:6px 12px 10px; }
- .doc-id-grid .settings-doc-card { padding:8px; }
- .doc-id-section-title { padding:4px 12px 0; font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
- .doc-id-section-toggle { display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
- .doc-id-section-toggle:hover { color:var(--text); }
- .settings-doc-card.is-locked { opacity:0.78; }
- .settings-doc-card.is-locked input { opacity:0.65; cursor:not-allowed; }
- .settings-doc-header { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
- .settings-doc-card .settings-doc-title { font-size:11px; color:var(--text); font-weight:600; }
- .settings-doc-card .settings-doc-meta { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.4px; }
- .settings-doc-card .settings-doc-ref { font-size:10px; color:var(--accent); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- .settings-doc-card .settings-doc-link { font-size:9px; color:var(--text2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- .settings-doc-actions { display:flex; gap:6px; margin-top:2px; }
- .settings-doc-action { flex:1; background:var(--bg2); border:1px solid var(--border); color:var(--text2); border-radius:4px; cursor:pointer; font-size:10px; padding:4px 6px; font-family:var(--font); }
- .settings-doc-action:hover { color:var(--accent); border-color:var(--accent); }
- .settings-doc-action:disabled { opacity:0.5; cursor:not-allowed; }
- .pc-doc-toggle { font-size:9px; opacity:0.5; cursor:pointer; padding:1px 4px; border-radius:3px; background:none; border:none; color:var(--text2); }
- .pc-doc-toggle:hover { opacity:1; }
- .pc-doc-toggle.active { color:var(--green); opacity:1; }
- .preview-urls { padding:4px 12px 8px; }
- .preview-urls h4 { font-size:9px; color:var(--text2); margin-bottom:4px; text-transform:uppercase; letter-spacing:0.5px; }
- .preview-url-item { display:flex; align-items:center; gap:6px; padding:3px 6px; border-radius:3px; cursor:pointer; font-size:10px; color:var(--green); margin-bottom:2px; }
- .preview-url-item:hover { background:var(--bg3); }
- .preview-url-item .pui-name { font-weight:600; min-width:40px; }
- .preview-url-item .pui-url { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
- /* Cloud panel */
- .cloud-dot { width:6px; height:6px; border-radius:50%; background:var(--red); display:inline-block; }
- .cloud-dot.connected { background:var(--green); }
- .cloud-section { }
- .cloud-user { padding:4px 12px; font-size:10px; color:var(--text); display:flex; align-items:center; gap:6px; }
- .cloud-user .cu-name { font-weight:600; color:var(--accent); }
- .cloud-user .cu-company { color:var(--text2); font-size:9px; }
- .cloud-actions { display:flex; gap:4px; padding:4px 12px; }
- .cloud-actions .sa-btn { flex:1; text-align:center; font-size:9px; padding:3px 0; }
- .cloud-gid { padding:2px 0; }
- .cloud-status { padding:4px 12px; font-size:9px; }
- .cloud-status.syncing { color:var(--yellow); }
- .cloud-status.ok { color:var(--green); }
- .cloud-status.error { color:var(--red); }
- .cloud-app-item { padding:3px 12px; font-size:10px; cursor:pointer; color:var(--text2); display:flex; justify-content:space-between; align-items:center; }
- .cloud-app-item:hover { background:var(--bg3); color:var(--text); }
- .cloud-app-item .ca-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
- .cloud-app-item .ca-gid { font-size:8px; color:var(--text2); opacity:0.6; }
- #cloudBtn.connected { color:var(--green); }
- .cloud-login-tabs { display:flex; gap:2px; margin-bottom:12px; border-bottom:1px solid var(--border); }
- .cl-tab { padding:6px 14px; cursor:pointer; color:var(--text2); font-size:11px; font-weight:600; border-bottom:2px solid transparent; transition:all 0.15s; }
- .cl-tab:hover { color:var(--text); }
- .cl-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
- .cl-panel { }
- .cl-panel code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:10px; }
- /* Auth status in header (Claude Code style) */
- .auth-status { display:flex; align-items:center; gap:6px; font-size:11px; cursor:pointer; padding:3px 8px; border-radius:5px; border:1px solid var(--border); background:var(--bg3); }
- .auth-status:hover { background:var(--border); }
- .auth-status .auth-dot { width:6px; height:6px; border-radius:50%; background:var(--red); flex-shrink:0; }
- .auth-status .auth-dot.ok { background:var(--green); }
- .auth-status .auth-name { color:var(--text); max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .auth-status .auth-label { color:var(--text2); }
- /* Message context toggle */
- .msg-ctx-toggle { position:absolute; top:4px; right:4px; background:none; border:none; color:var(--text2); cursor:pointer; font-size:10px; opacity:0; padding:2px 5px; border-radius:3px; transition:opacity 0.15s; }
- .msg:hover .msg-ctx-toggle { opacity:0.6; }
- .msg-ctx-toggle:hover { opacity:1 !important; background:var(--bg3); }
- .msg-ctx-toggle.excluded { color:var(--red); opacity:0.8 !important; }
- .msg.excluded-msg { opacity:0.4; border-left:2px solid var(--red); }
- .msg.excluded-msg .label::after { content:' (excluded from context)'; color:var(--red); font-size:8px; }
- /* Preview mode tab */
- .mode-tab[data-mode="preview"] { color:var(--green); }
- .mode-tab[data-mode="preview"].active { color:var(--green); border-bottom-color:var(--green); }
- .preview-bar { display:flex; align-items:center; gap:8px; padding:4px 12px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:10px; }
- .preview-bar .preview-url { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .preview-bar .preview-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
- .preview-bar .preview-btn:hover { background:var(--border); color:var(--text); }
- .preview-bar select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 4px; font-family:var(--font); font-size:9px; outline:none; }
- /* Flow toolbar */
- .flow-toolbar { display:flex; align-items:center; justify-content:space-between; padding:4px 10px; background:var(--bg2); border-bottom:1px solid var(--border); gap:8px; }
- .flow-sub-tabs { display:flex; gap:2px; }
- .flow-sub-tab { padding:3px 12px; cursor:pointer; color:var(--text2); font-size:10px; font-weight:600; border-radius:4px; transition:all 0.15s; }
- .flow-sub-tab:hover { color:var(--text); background:var(--bg3); }
- .flow-sub-tab.active { color:var(--accent); background:var(--bg3); }
- .flow-actions { display:flex; align-items:center; gap:6px; }
- .flow-actions select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 6px; font-family:var(--font); font-size:9px; outline:none; max-width:180px; }
- .flow-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
- .flow-btn:hover { background:var(--border); color:var(--text); }
- .flow-btn-run { background:var(--green); color:#000; border-color:var(--green); font-weight:700; }
- .flow-btn-run:hover { background:#2ea043; }
- .flow-btn-run:disabled { opacity:0.4; cursor:not-allowed; }
- .flow-btn-run.running { background:var(--orange); border-color:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
- .flow-run-status { font-size:9px; color:var(--text2); max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- /* Flow tab — workflow picker (Generate / Adjust sub-tabs) */
- .flow-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); font-size:10px; }
- .flow-wf-list.visible { display:flex; flex-wrap:wrap; gap:4px; padding:5px 10px; }
- .flow-wf-item { display:flex; flex-direction:column; gap:1px; padding:4px 10px; border-radius:4px; cursor:pointer; border:1px solid var(--border); color:var(--text2); min-width:90px; max-width:200px; }
- .flow-wf-item:hover { background:var(--bg3); color:var(--text); border-color:var(--accent); }
- .flow-wf-item.active { background:var(--accent); color:#fff; border-color:var(--accent); }
- .flow-wf-item .fwi-name { font-weight:600; font-size:10px; }
- .flow-wf-item .fwi-desc { font-size:8.5px; opacity:0.7; line-height:1.2; }
- .flow-wf-item.active .fwi-desc { opacity:0.85; }
- /* AutoTest 3-layer workflow hierarchy */
- .at-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); max-height:200px; overflow-y:auto; font-size:10px; }
- .at-wf-list.visible { display:block; }
- .at-wf-pipeline { display:flex; align-items:center; gap:6px; padding:4px 10px; cursor:pointer; color:var(--orange); font-weight:600; border-left:3px solid transparent; border-bottom:1px solid var(--border); }
- .at-wf-pipeline:hover { background:var(--bg2); }
- .at-wf-pipeline.active { background:var(--bg2); border-left-color:var(--orange); }
- .at-wf-apps { display:flex; flex-wrap:wrap; gap:4px; padding:4px 10px; border-bottom:1px solid var(--border); }
- .at-wf-app { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:3px; cursor:pointer; color:var(--text2); background:var(--bg2); border:1px solid transparent; font-size:10px; }
- .at-wf-app:hover { color:var(--text); border-color:var(--border); }
- .at-wf-app.active { color:var(--accent); border-color:var(--accent); background:rgba(100,180,255,0.1); }
- .at-wf-app .app-count { font-size:8px; color:var(--text2); margin-left:2px; }
- .at-wf-cases { display:flex; flex-wrap:wrap; gap:3px; padding:3px 10px; }
- .at-wf-case { display:inline-flex; align-items:center; gap:3px; padding:1px 6px; border-radius:2px; cursor:pointer; font-size:9px; color:var(--text2); background:var(--bg2); border:1px solid transparent; }
- .at-wf-case:hover { color:var(--text); border-color:var(--border); }
- .at-wf-case.active { color:var(--accent); border-color:var(--accent); }
- .at-wf-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; display:inline-block; }
- .at-wf-dot.idle { background:var(--text2); }
- .at-wf-dot.running { background:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
- .at-wf-dot.done { background:var(--green); }
- .at-wf-dot.error { background:var(--red); }
- .at-wf-dot.skipped { background:#cc0; }
- .at-wf-dot.soft { background:#cc0; }
- /* Editor + Chat */
- .content { flex:1; display:flex; flex-direction:column; }
- .panels { flex:1; display:flex; overflow:hidden; margin-right:400px; }
- /* Editor */
- .editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); }
- .editor-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:32px; }
- .editor-tabs .tab { padding:6px 14px; cursor:pointer; color:var(--text2); border-bottom:2px solid transparent; white-space:nowrap; font-size:11px; display:flex; align-items:center; gap:6px; }
- .editor-tabs .tab.active { color:var(--text); border-bottom-color:var(--accent); background:var(--bg); }
- .editor-tabs .tab .tab-icon { font-size:10px; opacity:0.7; }
- .editor-tabs .tab .tab-close { font-size:9px; opacity:0; padding:1px 3px; border-radius:3px; line-height:1; }
- .editor-tabs .tab:hover .tab-close { opacity:0.5; }
- .editor-tabs .tab .tab-close:hover { opacity:1; background:var(--bg3); }
- .editor-area { flex:1; position:relative; overflow:hidden; }
- .editor-area textarea { width:100%; height:100%; background:var(--bg); color:var(--text); border:none; padding:12px; font-family:var(--font); font-size:13px; line-height:1.6; resize:none; outline:none; tab-size:2; display:none; }
- .editor-area .CodeMirror { width:100%; height:100%; font-family:'SF Mono','Fira Code','JetBrains Mono',monospace; font-size:13px; line-height:1.6; background:var(--bg); }
- .editor-area .CodeMirror-gutters { background:var(--bg2); border-right:1px solid var(--border); }
- .editor-area .CodeMirror-linenumber { color:var(--text2); opacity:0.5; padding:0 8px 0 4px; }
- .editor-area .CodeMirror-cursor { border-left:2px solid var(--accent); }
- .editor-area .CodeMirror-selected { background:rgba(88,166,255,0.15); }
- .editor-area .CodeMirror-activeline-background { background:rgba(255,255,255,0.03); }
- .editor-area .CodeMirror-matchingbracket { color:var(--green) !important; text-decoration:underline; }
- .editor-area .CodeMirror-foldgutter { width:14px; }
- .editor-area .CodeMirror-foldgutter-open, .editor-area .CodeMirror-foldgutter-folded { color:var(--text2); }
- /* Dark theme token colors — VL optimized, no black text */
- .editor-area .cm-keyword { color:#ff7b72; font-weight:600; }
- .editor-area .cm-variable-2 { color:#ffa657; }
- .editor-area .cm-def { color:#d2a8ff; }
- .editor-area .cm-string { color:#a5d6ff; }
- .editor-area .cm-number { color:#79c0ff; }
- .editor-area .cm-atom { color:#79c0ff; }
- .editor-area .cm-type { color:#79c0ff; font-weight:600; }
- .editor-area .cm-builtin { color:#7ee787; }
- .editor-area .cm-tag { color:#7ee787; font-weight:600; }
- .editor-area .cm-attribute { color:#79c0ff; }
- .editor-area .cm-property { color:#c9d1d9; }
- .editor-area .cm-comment { color:#8b949e; font-style:italic; }
- .editor-area .cm-meta { color:#8b949e; }
- .editor-area .cm-qualifier { color:#6e7681; }
- .editor-area .cm-indent-marker { color:#6e7681; }
- .editor-area .cm-section-header { color:#d2a8ff; font-weight:700; font-size:14px; }
- .editor-area .cm-operator { color:#e6edf3; }
- .editor-area .cm-variable { color:#c9d1d9; }
- .editor-area .cm-variable-3 { color:#ffa657; }
- .editor-area .cm-bracket { color:#e6edf3; }
- .editor-area .cm-punctuation { color:#8b949e; }
- .editor-area .cm-link { color:#58a6ff; }
- .editor-area .CodeMirror-line { color:var(--text); }
- .editor-area .CodeMirror pre.CodeMirror-line { color:#e6edf3; } /* JSON punctuation: unstyled spans inherit this */
- .editor-area .CodeMirror-scroll { scrollbar-color:var(--bg3) transparent; overflow:scroll !important; }
- .editor-area .CodeMirror-hscrollbar { height:10px !important; }
- .editor-area .CodeMirror-hscrollbar div { background:var(--bg3) !important; border-radius:4px; }
- .editor-area .iframe-container { display:none; width:100%; height:100%; }
- .editor-area .iframe-container iframe { width:100%; height:100%; border:none; background:#fff; }
- .editor-placeholder { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); color:var(--text2); text-align:center; line-height:2; }
- /* Code preview (read-only syntax view) */
- .code-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; }
- .code-preview pre { padding:12px; font-family:var(--font); font-size:13px; line-height:1.6; tab-size:2; margin:0; counter-reset:line; }
- .code-preview .line { display:block; white-space:pre-wrap; word-break:break-all; }
- .code-preview .line::before { counter-increment:line; content:counter(line); display:inline-block; width:3.5em; margin-right:1em; text-align:right; color:var(--text2); opacity:0.4; font-size:11px; user-select:none; }
- /* VL Syntax colors */
- .code-preview .kw { color:#ff7b72; } /* keywords: SERVICE, PUBLIC_SERVICE, SECTION, EVENT, etc */
- .code-preview .str { color:#a5d6ff; } /* strings */
- .code-preview .cmt { color:#8b949e; font-style:italic; } /* comments */
- .code-preview .var { color:#ffa657; } /* $variables */
- .code-preview .evt { color:#d2a8ff; } /* @events */
- .code-preview .type { color:#79c0ff; } /* type names */
- .code-preview .num { color:#79c0ff; } /* numbers */
- .code-preview .tag { color:#7ee787; } /* <Component-X>, <Section-Y> */
- .code-preview .prop { color:#d2a8ff; } /* property keys in JSON */
- /* Markdown preview */
- .md-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; padding:16px 24px; color:var(--text); line-height:1.7; }
- .md-preview h1 { font-size:20px; border-bottom:1px solid var(--border); padding-bottom:8px; margin:16px 0 12px; }
- .md-preview h2 { font-size:17px; border-bottom:1px solid var(--border); padding-bottom:6px; margin:14px 0 10px; }
- .md-preview h3 { font-size:14px; margin:12px 0 8px; }
- .md-preview p { margin:8px 0; }
- .md-preview ul, .md-preview ol { margin:8px 0; padding-left:24px; }
- .md-preview code { background:var(--bg3); padding:2px 5px; border-radius:3px; font-size:12px; }
- .md-preview pre { background:var(--bg3); padding:12px; border-radius:6px; overflow-x:auto; margin:8px 0; }
- .md-preview pre code { background:none; padding:0; }
- .md-preview blockquote { border-left:3px solid var(--accent); padding-left:12px; color:var(--text2); }
- /* Chat panel — fixed floating, draggable, always visible */
- .chat-panel { position:fixed; right:0; top:32px; width:400px; height:calc(100vh - 32px); z-index:100; display:flex; flex-direction:column; background:var(--bg); border:1px solid var(--border); box-shadow:-4px 0 16px rgba(0,0,0,0.3); border-radius:0; transition:none; }
- .chat-panel.floating { border-radius:8px; height:auto; bottom:auto; resize:both; overflow:hidden; min-height:300px; min-width:300px; max-width:800px; }
- .chat-resize-handle { position:absolute; left:-3px; top:0; bottom:0; width:6px; cursor:col-resize; z-index:101; }
- .chat-resize-handle:hover, .chat-resize-handle.dragging { background:var(--accent); opacity:0.3; }
- .chat-panel.collapsed { width:36px !important; min-width:36px; }
- .chat-panel.collapsed .chat-messages, .chat-panel.collapsed .chat-input-area, .chat-panel.collapsed .conv-tabs, .chat-panel.collapsed .chat-search, .chat-panel.collapsed .chat-resize-handle, .chat-panel.collapsed .chat-actions { display:none !important; }
- .chat-panel.collapsed .chat-header { writing-mode:vertical-rl; text-orientation:mixed; padding:12px 6px; cursor:pointer; justify-content:center; border-bottom:none; }
- .chat-panel.collapsed .chat-header>*:not(.chat-collapse-btn) { display:none; }
- .chat-panel.collapsed .chat-collapse-btn { writing-mode:horizontal-tb; }
- .chat-collapse-btn { background:none; border:none; color:var(--text2); cursor:pointer; font-size:12px; padding:2px 4px; border-radius:3px; }
- .chat-collapse-btn:hover { color:var(--text); background:var(--bg3); }
- /* Detail Panel */
- .detail-panel { position:fixed; right:401px; top:32px; width:420px; height:calc(100vh - 32px); z-index:99; display:none; flex-direction:column; background:var(--bg); border:1px solid var(--border); border-right:none; box-shadow:-4px 0 16px rgba(0,0,0,0.3); border-radius:0; }
- .detail-panel.open { display:flex; }
- .detail-header { padding:6px 10px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:10px; color:var(--text2); display:flex; justify-content:space-between; align-items:center; border-radius:0; cursor:grab; user-select:none; }
- .detail-header .dh-title { font-weight:600; color:var(--orange); }
- .detail-body { flex:1; overflow-y:auto; padding:8px; font-size:10px; font-family:var(--font); }
- .detail-entry { margin-bottom:6px; padding:4px 6px; border-left:2px solid var(--border); }
- .detail-entry.info { border-left-color:var(--accent); }
- .detail-entry.success { border-left-color:var(--green); }
- .detail-entry.error { border-left-color:var(--red); }
- .detail-entry.warn { border-left-color:var(--orange); }
- .detail-entry.depth-1 { margin-left:14px; border-left-style:dashed; }
- .detail-entry.depth-2 { margin-left:28px; border-left-style:dotted; }
- .detail-entry.depth-3 { margin-left:42px; border-left-style:dotted; opacity:0.85; }
- .detail-entry .de-time { color:var(--text2); font-size:8px; }
- .detail-entry .de-phase { color:var(--orange); font-weight:600; margin-left:4px; font-size:9px; }
- .detail-entry .de-agent { color:var(--purple); font-weight:600; margin-left:4px; font-size:9px; }
- .detail-entry .de-msg { color:var(--text); margin-top:2px; white-space:pre-wrap; word-break:break-word; }
- .detail-entry .de-data { color:var(--text2); margin-top:2px; white-space:pre-wrap; word-break:break-word; font-size:9px; max-height:300px; overflow-y:auto; background:var(--bg2); padding:4px; border-radius:3px; cursor:pointer; }
- .detail-entry .de-data.collapsed { max-height:40px; overflow:hidden; position:relative; }
- .detail-entry .de-data.collapsed::after { content:'... click to expand'; position:absolute; bottom:0; right:4px; background:var(--bg2); padding:0 4px; color:var(--accent); font-size:8px; }
- /* Step card in detail panel — enhanced workflow step display */
- .detail-step-card { margin:6px 0; background:var(--bg2); border-radius:5px; border:1px solid var(--border); overflow:hidden; }
- .detail-step-card.running { border-color:var(--orange); }
- .detail-step-card.done { border-color:var(--green); }
- .detail-step-card.error { border-color:var(--red); }
- .detail-step-card.skipped { border-color:var(--text2); opacity:0.7; }
- .dsc-header { display:flex; align-items:center; gap:6px; padding:5px 8px; cursor:pointer; font-size:10px; }
- .dsc-header:hover { background:var(--bg3); }
- .dsc-icon { font-size:12px; width:16px; text-align:center; }
- .dsc-title { color:var(--text); font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .dsc-type { color:var(--accent); font-size:8px; background:var(--bg); padding:1px 5px; border-radius:8px; }
- .dsc-duration { color:var(--text2); font-size:8px; }
- .dsc-body { padding:0 8px 6px; display:none; }
- .dsc-body.open { display:block; }
- .dsc-section { margin:4px 0; }
- .dsc-section-header { display:flex; align-items:center; gap:4px; font-size:9px; color:var(--text2); cursor:pointer; padding:2px 0; }
- .dsc-section-header:hover { color:var(--accent); }
- .dsc-section-header .dsc-arrow { font-size:8px; transition:transform 0.15s; }
- .dsc-section-header .dsc-arrow.open { transform:rotate(90deg); }
- .dsc-section-content { display:none; max-height:300px; overflow-y:auto; background:var(--bg); padding:4px 6px; border-radius:3px; font-size:9px; color:var(--text2); white-space:pre-wrap; word-break:break-word; margin-top:2px; }
- .dsc-section-content.open { display:block; }
- .dsc-section-content.truncated::after { content:'(truncated)'; color:var(--orange); font-style:italic; }
- .dsc-actions { display:flex; gap:6px; margin-top:6px; padding-top:4px; border-top:1px solid var(--border); }
- .dsc-rerun-btn { background:none; border:1px solid var(--accent); color:var(--accent); font-size:9px; padding:2px 8px; border-radius:3px; cursor:pointer; }
- .dsc-rerun-btn:hover { background:var(--accent); color:var(--bg); }
- /* Hover action buttons on step card header */
- .dsc-hover-actions { display:none; gap:3px; margin-left:auto; flex-shrink:0; }
- .detail-step-card:hover .dsc-hover-actions { display:flex; }
- .dsc-hover-btn { background:none; border:none; color:var(--text2); font-size:10px; cursor:pointer; padding:1px 4px; border-radius:3px; line-height:1; }
- .dsc-hover-btn:hover { background:var(--bg3); color:var(--accent); }
- /* Step card context menu */
- .step-ctx-menu { display:none; position:fixed; z-index:300; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:4px 0; min-width:180px; box-shadow:0 6px 20px rgba(0,0,0,0.5); }
- .step-ctx-menu.open { display:block; }
- .step-ctx-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); display:flex; align-items:center; gap:8px; }
- .step-ctx-item:hover { background:var(--bg3); color:var(--text); }
- .step-ctx-item .sci-icon { width:16px; text-align:center; font-size:12px; }
- .step-ctx-item .sci-label { flex:1; }
- .step-ctx-item .sci-hint { font-size:9px; color:var(--text2); opacity:0.6; }
- .step-ctx-sep { height:1px; background:var(--border); margin:3px 0; }
- /* File list in step card */
- .dsc-file { font-size:9px; color:var(--green); padding:1px 0; }
- .dsc-file::before { content:'📄 '; }
- /* Re-run dialog variable rows */
- .rr-var-row { margin:4px 0; }
- .rr-var-name { font-size:10px; font-weight:600; color:var(--accent); margin-bottom:2px; }
- .rr-var-val { width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text); font-size:9px; font-family:monospace; padding:4px 6px; border-radius:3px; resize:vertical; }
- .rr-var-val:focus { border-color:var(--accent); outline:none; }
- /* Agent group in detail panel */
- .detail-agent-group { margin:4px 0; background:var(--bg2); border-radius:4px; border:1px solid var(--border); overflow:hidden; }
- .detail-agent-header { display:flex; align-items:center; gap:6px; padding:4px 8px; cursor:pointer; font-size:10px; }
- .detail-agent-header:hover { background:var(--bg3); }
- .detail-agent-header .dag-icon { color:var(--purple); font-size:11px; }
- .detail-agent-header .dag-name { color:var(--accent); font-weight:600; }
- .detail-agent-header .dag-desc { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .detail-agent-header .dag-status { font-size:9px; }
- .detail-agent-children { padding:0 4px 4px; }
- .detail-agent-children.collapsed { display:none; }
- .detail-entry.stream-box { border-left-color:var(--accent); padding:0; }
- .de-stream-header { display:flex; align-items:center; gap:6px; padding:4px 6px; cursor:pointer; background:var(--bg2); border-radius:3px 3px 0 0; }
- .de-stream-header:hover { background:var(--hover); }
- .de-stream-label { color:var(--orange); font-weight:600; font-size:9px; }
- .de-stream-size { color:var(--text2); font-size:8px; margin-left:auto; }
- .de-stream-toggle { color:var(--text2); font-size:8px; transition:transform .2s; }
- .stream-box.collapsed .de-stream-toggle { transform:rotate(-90deg); }
- .de-stream-content { white-space:pre-wrap; word-break:break-all; font-size:9px; color:var(--text); max-height:300px; overflow-y:auto; padding:4px 6px; background:var(--bg1); border-radius:0 0 3px 3px; }
- .stream-box.collapsed .de-stream-content { display:none; }
- /* LLM communication phase colors */
- .detail-entry .de-phase[data-phase="llm"] { color:var(--purple); }
- .detail-entry .de-phase[data-phase="tool-call"] { color:var(--blue); }
- .detail-entry .de-phase[data-phase="tool-result"] { color:var(--green); }
- .detail-entry .de-phase[data-phase="var"] { color:var(--cyan, #5ccfe6); }
- .detail-entry .de-phase[data-phase="file"] { color:var(--yellow); }
- .detail-entry .de-phase[data-phase="step"] { color:var(--accent); }
- .detail-entry .de-phase[data-phase="node"] { color:var(--accent); font-weight:600; }
- .detail-entry .de-phase[data-phase="tool"] { color:var(--blue); }
- .detail-entry .de-phase[data-phase="result"] { color:var(--green); }
- /* Thinking stream box: distinct purple accent */
- .detail-entry.stream-box.thinking-stream { border-left-color:var(--purple); }
- .thinking-stream .de-stream-label { color:var(--purple); }
- .thinking-stream .de-stream-content { color:var(--text2); font-style:italic; }
- /* Workflow LLM chat streaming */
- .wf-tool-full.collapsed { display:none; }
- /* Message truncation + show more — only for extremely long messages */
- .msg.assistant .content-text.truncated { max-height:2000px; overflow:hidden; position:relative; }
- .msg.assistant .content-text.truncated::after { content:''; position:absolute; bottom:0; left:0; right:0; height:40px; background:linear-gradient(transparent, var(--bg)); pointer-events:none; }
- .msg-toggle { display:block; color:var(--accent); font-size:11px; cursor:pointer; margin-top:6px; border:1px solid var(--accent); background:none; font-family:var(--font); padding:2px 10px; border-radius:4px; text-align:center; }
- .msg-toggle:hover { background:var(--accent); color:#fff; }
- /* Compact mode — collapse tool groups */
- .chat-panel.compact .tool-group .tool-body { display:none !important; }
- .chat-panel.compact .msg.assistant .content-text.auto-truncate { max-height:100px; overflow:hidden; }
- .chat-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 10px; background:linear-gradient(180deg, #161c25 0%, #131922 100%); border-bottom:1px solid var(--border); }
- .chat-action-group { display:flex; align-items:center; gap:6px; min-width:0; }
- .chat-actions .ca-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:4px 10px; border-radius:999px; cursor:pointer; font-family:var(--font); font-size:9px; line-height:1; white-space:nowrap; }
- .chat-actions .ca-btn:hover { background:var(--border); color:var(--text); }
- .chat-actions .ca-btn.ca-primary { color:var(--text); background:rgba(88,166,255,0.09); border-color:rgba(88,166,255,0.22); }
- .chat-actions .ca-btn.ca-primary:hover { background:rgba(88,166,255,0.16); border-color:rgba(88,166,255,0.38); }
- .chat-actions .ca-btn.ca-log { color:var(--orange); border-color:rgba(240,136,62,0.24); background:rgba(240,136,62,0.08); }
- .chat-actions .ca-btn.ca-log:hover { background:rgba(240,136,62,0.14); color:#ffd4b6; }
- .ca-menu { position:relative; }
- .ca-menu-panel { display:none; position:absolute; top:calc(100% + 6px); right:0; min-width:150px; background:var(--bg2); border:1px solid var(--border); border-radius:10px; padding:6px; box-shadow:0 12px 32px rgba(0,0,0,0.35); z-index:90; }
- .ca-menu.open .ca-menu-panel { display:block; }
- .ca-menu-item { display:block; width:100%; text-align:left; background:none; border:none; color:var(--text2); font-family:var(--font); font-size:10px; padding:7px 8px; border-radius:7px; cursor:pointer; }
- .ca-menu-item:hover { background:var(--bg3); color:var(--text); }
- .ca-menu-item.menu-accent { color:var(--accent); }
- .ca-menu-item.menu-log { color:var(--orange); }
- /* Workflow progress widget in chat */
- .wf-progress { background:var(--bg2); border:1px solid var(--border); border-radius:6px; margin:6px 0; padding:8px 10px; font-size:10px; }
- .wf-progress-header { display:flex; align-items:center; gap:6px; margin-bottom:6px; font-weight:600; color:var(--text); font-size:11px; }
- .wf-progress-header .wf-icon { font-size:12px; }
- .wf-step { display:flex; align-items:center; gap:6px; padding:2px 0; color:var(--text2); }
- .wf-step-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--border); transition:background 0.3s; }
- .wf-step-dot.pending { background:var(--border); }
- .wf-step-dot.running { background:var(--yellow); animation:wfpulse 1s ease-in-out infinite; }
- .wf-step-dot.done { background:var(--green); }
- .wf-step-dot.error { background:var(--red); }
- .wf-step-dot.paused { background:var(--purple); animation:wfpulse 1.5s ease-in-out infinite; }
- .wf-step-dot.skipped { background:var(--text2); opacity:0.4; }
- @keyframes wfpulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(1.3)} }
- .wf-step.active { color:var(--text); font-weight:500; }
- .wf-step.completed { color:var(--text2); opacity:0.6; }
- .wf-progress-actions { margin-top:6px; display:flex; gap:4px; }
- .wf-progress-actions button { background:var(--bg3); border:1px solid var(--border); color:var(--text2); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
- .wf-progress-actions button:hover { background:var(--border); color:var(--text); }
- .wf-approve-btn { border-color:var(--green) !important; color:var(--green) !important; }
- .wf-approve-btn:hover { background:var(--green) !important; color:#fff !important; }
- .wf-cancel-btn:hover { background:var(--red) !important; color:#fff !important; border-color:var(--red) !important; }
- .wf-step-type { font-size:8px; color:var(--text2); opacity:0.6; margin-left:auto; }
- .chat-header { padding:8px 14px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:11px; color:var(--text2); display:flex; justify-content:space-between; align-items:center; cursor:grab; user-select:none; }
- .chat-header:active { cursor:grabbing; }
- .chat-messages { flex:1; overflow-y:auto; padding:10px; }
- .msg { margin-bottom:8px; padding:6px 10px; border-radius:6px; line-height:1.5; white-space:pre-wrap; word-break:break-word; font-size:12px; position:relative; }
- .msg.user { background:var(--bg3); border:1px solid var(--border); }
- .msg.assistant { background:#161b22; border:1px solid #30363d; }
- .msg .label { font-size:9px; color:var(--text2); margin-bottom:3px; text-transform:uppercase; letter-spacing:0.5px; }
- .msg .msg-time { font-size:9px; color:var(--text2); opacity:0.7; text-transform:none; letter-spacing:0; margin-left:6px; }
- /* Claude Code-style compact tool indicators */
- .tool-group { margin:4px 0; background:var(--bg2); border-radius:6px; border:1px solid var(--border); overflow:hidden; }
- .tool-header { display:flex; align-items:center; gap:6px; padding:6px 10px; cursor:pointer; font-size:11px; color:var(--text2); }
- .tool-header:hover { background:var(--bg3); }
- .tool-header .tool-icon { width:16px; text-align:center; font-size:12px; flex-shrink:0; }
- .tool-header .tool-name { color:var(--accent); font-weight:600; min-width:50px; }
- .tool-header .tool-desc { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); }
- .tool-header .tool-time { font-size:9px; color:var(--text2); opacity:0.6; min-width:28px; text-align:right; flex-shrink:0; }
- .tool-header .tool-toggle { font-size:8px; color:var(--text2); transition:transform 0.2s; flex-shrink:0; }
- .tool-header .tool-toggle.open { transform:rotate(90deg); }
- .tool-body { display:none; padding:6px 10px 8px; font-size:10px; color:var(--text2); border-top:1px solid var(--border); max-height:200px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; }
- .tool-body.open { display:block; }
- .tool-detail { font-size:10px; color:var(--text2); padding:2px 0; display:flex; gap:6px; }
- .tool-detail .td-label { color:var(--text2); opacity:0.7; min-width:50px; }
- .tool-detail .td-val { color:var(--text); flex:1; }
- .tool-diff { margin:3px 0; font-family:var(--font); font-size:11px; }
- .tool-diff .td-old { color:var(--red); opacity:0.8; padding:1px 4px; background:rgba(248,81,73,0.08); border-radius:2px; }
- .tool-diff .td-new { color:var(--green); padding:1px 4px; background:rgba(63,185,80,0.08); border-radius:2px; }
- .tool-result-badge { display:inline-block; font-size:9px; padding:1px 6px; border-radius:8px; margin-left:6px; }
- .tool-result-badge.ok { background:rgba(63,185,80,0.15); color:var(--green); }
- .tool-result-badge.err { background:rgba(248,81,73,0.15); color:var(--red); }
- .tool-status-icon { font-size:12px; flex-shrink:0; }
- .tool-status-icon.running { color:var(--accent); }
- .tool-status-icon.done { color:var(--green); }
- .tool-status-icon.error { color:var(--red); }
- /* Spinner for active tool */
- @keyframes spin { to { transform:rotate(360deg); } }
- .tool-spinner { width:12px; height:12px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; }
- /* Todo list (compact) */
- .msg.todo-list { background:var(--bg2); font-size:11px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); }
- .todo-item { display:flex; align-items:center; gap:5px; padding:2px 0; }
- .todo-icon { width:12px; text-align:center; font-size:10px; }
- .todo-done { color:var(--green); }
- .todo-active { color:var(--yellow); }
- .todo-pending { color:var(--text2); }
- .todo-subtask { padding-left:20px; font-size:10px; }
- .todo-timing { margin-left:auto; font-size:9px; opacity:0.6; font-variant-numeric:tabular-nums; }
- .todo-spinner { width:10px; height:10px; border:2px solid var(--border); border-top-color:var(--yellow); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; margin-right:2px; }
- .todo-text { flex:1; }
- /* Thinking indicator */
- .thinking-block { margin:4px 0; padding:6px 10px; background:linear-gradient(135deg, #1a1e2e, #161b22); border-radius:6px; border:1px solid #30365d; font-size:11px; }
- .thinking-header { display:flex; align-items:center; gap:6px; color:var(--purple); cursor:pointer; }
- .thinking-header .think-icon { animation:pulse 1.5s ease-in-out infinite; }
- @keyframes pulse { 0%,100% { opacity:0.5; } 50% { opacity:1; } }
- .thinking-body { display:none; margin-top:4px; color:var(--text2); font-size:10px; max-height:100px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; }
- .thinking-body.open { display:block; }
- .thinking-block.done .think-icon { animation:none; opacity:0.5; }
- .thinking-block.done .thinking-header { color:var(--text2); }
- /* Markdown in assistant messages */
- .msg.assistant .content-text { white-space:normal; }
- .msg.assistant .content-text p { margin:4px 0; }
- .msg.assistant .content-text code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:11px; }
- .msg.assistant .content-text pre { background:var(--bg); border:1px solid var(--border); border-radius:4px; padding:8px; margin:6px 0; overflow-x:auto; }
- .msg.assistant .content-text pre code { background:none; padding:0; }
- .msg.assistant .content-text h1,.msg.assistant .content-text h2,.msg.assistant .content-text h3 { margin:8px 0 4px; font-size:13px; color:var(--accent); }
- .msg.assistant .content-text ul,.msg.assistant .content-text ol { padding-left:18px; margin:4px 0; }
- .msg.assistant .content-text li { margin:2px 0; }
- .msg.assistant .content-text strong { color:var(--text); }
- .msg.assistant .content-text a { color:var(--accent); text-decoration:none; }
- .msg.assistant .content-text blockquote { border-left:2px solid var(--border); padding-left:8px; color:var(--text2); margin:4px 0; }
- /* Retry indicator */
- .retry-msg { margin:4px 0; padding:4px 10px; font-size:10px; color:var(--yellow); display:flex; align-items:center; gap:5px; }
- /* Token details in context bar */
- .ctx-tooltip { position:relative; cursor:help; }
- .ctx-detail { display:none; position:absolute; bottom:20px; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:8px 12px; font-size:10px; white-space:nowrap; z-index:50; min-width:180px; }
- .ctx-tooltip:hover .ctx-detail { display:block; }
- .chat-input { display:flex; border-top:1px solid var(--border); background:var(--bg2); }
- .chat-input input { flex:1; background:transparent; border:none; color:var(--text); padding:10px 14px; font-family:var(--font); font-size:12px; outline:none; }
- .chat-input button { background:var(--accent); color:#fff; border:none; padding:6px 14px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; }
- .chat-input button:disabled { opacity:0.4; cursor:default; }
- /* Bottom bar */
- .bottom-bar { background:var(--bg2); border-top:1px solid var(--border); padding:3px 16px; display:flex; gap:16px; font-size:10px; color:var(--text2); align-items:center; }
- .bottom-bar .status { display:flex; align-items:center; gap:5px; }
- .bottom-bar .dot { width:6px; height:6px; border-radius:50%; }
- .dot-green { background:var(--green); }
- .dot-yellow { background:var(--yellow); }
- .dot-red { background:var(--red); }
- /* Modals */
- .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6); z-index:100; justify-content:center; align-items:center; }
- .modal-overlay.open { display:flex; }
- .modal-box { background:var(--bg2); border:1px solid var(--border); border-radius:10px; width:560px; max-height:80vh; overflow-y:auto; padding:20px; }
- .modal-box h2 { margin-bottom:14px; font-size:16px; }
- .modal-box label { display:block; font-size:11px; color:var(--text2); margin-bottom:4px; margin-top:12px; }
- .modal-box input[type=text], .modal-box input[type=password], .modal-box select, .modal-box textarea {
- width:100%; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px;
- padding:8px 10px; font-family:var(--font); font-size:12px; outline:none; }
- .modal-box input:focus, .modal-box select:focus, .modal-box textarea:focus { border-color:var(--accent); }
- .modal-box textarea { height:100px; resize:vertical; }
- .modal-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
- .modal-actions .hdr-btn { padding:6px 18px; }
- /* Settings-specific */
- .key-row { display:flex; gap:6px; align-items:center; }
- .key-row input { flex:1; }
- .key-row button { flex-shrink:0; }
- .key-status { font-size:10px; margin-top:3px; }
- .key-ok { color:var(--green); }
- .key-missing { color:var(--red); }
- .model-option { padding:2px 0; }
- .model-desc { font-size:10px; color:var(--text2); }
- /* Setup overlay (shown when no API key) */
- /* Landing / Login page */
- .landing-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:var(--bg); z-index:300; justify-content:center; align-items:center; padding:24px; overflow:auto; }
- .landing-overlay.active { display:flex; }
- .landing-shell { width:min(1360px, 100%); min-height:min(860px, calc(100vh - 48px)); display:grid; grid-template-columns:minmax(380px, 520px) minmax(0, 1fr); gap:18px; align-items:stretch; }
- .landing-box { width:100%; text-align:center; background:linear-gradient(180deg, rgba(17,22,29,0.98) 0%, rgba(13,17,23,0.98) 100%); border:1px solid var(--border); border-radius:14px; padding:24px; box-shadow:0 18px 42px rgba(0,0,0,0.3); }
- .landing-brand { display:flex; flex-direction:column; align-items:center; gap:12px; margin-bottom:14px; }
- .landing-brand img { width:120px; height:auto; filter:drop-shadow(0 14px 32px rgba(23, 94, 183, 0.28)); }
- .landing-box h1 { color:var(--accent); font-size:28px; margin-bottom:4px; }
- .landing-box .landing-sub { color:var(--text2); font-size:12px; margin-bottom:20px; line-height:1.5; }
- .app-brand { display:flex; align-items:center; gap:10px; }
- .app-brand img { width:20px; height:20px; border-radius:6px; }
- .landing-box input { width:100%; margin-bottom:8px; }
- .landing-box .hdr-btn { width:100%; padding:8px; font-size:12px; }
- .landing-section { background:var(--bg2); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; text-align:left; }
- .landing-section h3 { font-size:12px; color:var(--text); margin-bottom:10px; display:flex; align-items:center; gap:6px; }
- .landing-section h3 .ls-badge { font-size:9px; padding:2px 6px; border-radius:3px; font-weight:400; }
- .landing-section h3 .ls-badge.recommended { background:var(--green); color:#fff; }
- .landing-section h3 .ls-badge.optional { background:var(--bg3); color:var(--text2); }
- .landing-tabs { display:flex; gap:0; margin-bottom:10px; border-bottom:1px solid var(--border); }
- .landing-tab { padding:6px 14px; font-size:11px; color:var(--text2); cursor:pointer; border-bottom:2px solid transparent; font-family:var(--font); }
- .landing-tab:hover { color:var(--text); }
- .landing-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
- .landing-tab-panel { display:none; }
- .landing-tab-panel.active { display:block; }
- .landing-or { text-align:center; color:var(--text2); font-size:10px; margin:8px 0; position:relative; }
- .landing-or::before, .landing-or::after { content:''; position:absolute; top:50%; width:40%; height:1px; background:var(--border); }
- .landing-or::before { left:0; }
- .landing-or::after { right:0; }
- .landing-skip { text-align:center; margin-top:8px; }
- .landing-skip a { color:var(--text2); font-size:11px; cursor:pointer; text-decoration:underline; }
- .landing-skip a:hover { color:var(--accent); }
- .landing-docs { min-width:0; background:linear-gradient(180deg, rgba(17,22,29,0.98) 0%, rgba(13,17,23,0.98) 100%); border:1px solid var(--border); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 18px 42px rgba(0,0,0,0.3); }
- .landing-docs-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:18px 20px 14px; border-bottom:1px solid var(--border); background:rgba(22,27,34,0.88); }
- .landing-docs-head h2 { font-size:16px; color:var(--text); margin:0 0 4px; }
- .landing-docs-copy { font-size:11px; color:var(--text2); line-height:1.6; }
- .landing-docs-note { padding:10px 20px; border-bottom:1px solid var(--border); background:rgba(88,166,255,0.06); font-size:11px; color:var(--text2); line-height:1.6; }
- .landing-docs-note code { color:var(--accent); }
- .landing-docs-frame { flex:1; width:100%; border:0; background:var(--bg); min-height:560px; }
- @media (max-width: 1100px) {
- .landing-shell { grid-template-columns:1fr; min-height:auto; }
- .landing-docs-frame { min-height:460px; }
- }
- /* LLM provider indicator */
- .llm-badge { display:inline-flex; align-items:center; gap:4px; font-size:9px; padding:1px 6px; border-radius:3px; font-family:var(--font); cursor:pointer; }
- .llm-badge.cli { background:#3fb95022; color:var(--green); border:1px solid #3fb95044; }
- .llm-badge.apikey { background:#58a6ff22; color:var(--accent); border:1px solid #58a6ff44; }
- /* Gen progress */
- .gen-progress { margin-top:14px; }
- .gen-step { padding:4px 0; display:flex; align-items:center; gap:6px; font-size:11px; }
- /* Drag-and-drop overlay */
- .drop-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(88,166,255,0.12); border:3px dashed var(--accent); z-index:200; justify-content:center; align-items:center; cursor:pointer; }
- .drop-overlay.active { display:flex; }
- .drop-overlay .drop-msg { pointer-events:none; }
- .drop-overlay .drop-msg { background:var(--bg2); border:1px solid var(--accent); border-radius:12px; padding:24px 36px; text-align:center; }
- .drop-overlay .drop-msg h2 { color:var(--accent); font-size:18px; margin-bottom:6px; }
- .drop-overlay .drop-msg p { color:var(--text2); font-size:12px; }
- /* Workspace display — current workspace only */
- .ws-tabs { display:flex; align-items:center; min-width:0; max-width:260px; flex:0 1 260px; }
- .ws-current { display:flex; align-items:center; gap:8px; min-width:0; width:100%; background:linear-gradient(180deg, #1b2430 0%, #161d27 100%); border:1px solid #2d3743; border-radius:7px; padding:4px 10px; cursor:pointer; color:var(--text); transition:all 0.15s; }
- .ws-current:hover { border-color:#3a4654; background:linear-gradient(180deg, #202a36 0%, #18212b 100%); }
- .ws-current.empty { color:var(--text2); }
- .ws-current-icon { color:var(--green); font-size:9px; flex-shrink:0; }
- .ws-current.empty .ws-current-icon { color:var(--text2); }
- .ws-current-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:600; }
- .ws-current.ws-btn-highlight { border-color:var(--accent); color:var(--accent); animation:wsBtnPulse 1.5s ease-in-out infinite; }
- @keyframes wsBtnPulse { 0%,100%{box-shadow:0 0 0 0 rgba(var(--accent-rgb,255,200,0),0.5)} 50%{box-shadow:0 0 0 4px rgba(var(--accent-rgb,255,200,0),0)} }
- /* Workspace popover (reuses old dropdown items) */
- .ws-popover { display:none; position:absolute; top:100%; left:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px; max-height:420px; overflow-y:auto; z-index:90; width:320px; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
- .ws-popover.open { display:block; }
- .ws-popover .ws-section { font-size:9px; color:var(--text2); padding:6px 12px; text-transform:uppercase; letter-spacing:0.5px; border-bottom:1px solid var(--border); }
- .ws-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; gap:6px; }
- .ws-item:hover { background:var(--bg3); }
- .ws-item.active { background:rgba(88,166,255,0.08); border-left:2px solid var(--accent); }
- .ws-item .ws-item-name { font-size:11px; color:var(--text); font-weight:600; }
- .ws-item .ws-item-path { font-size:9px; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:200px; }
- .ws-item .ws-del { color:var(--red); cursor:pointer; font-size:12px; opacity:0.5; padding:2px 4px; }
- .ws-item .ws-del:hover { opacity:1; }
- .ws-add { padding:8px 12px; display:flex; gap:6px; align-items:center; }
- .ws-add input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:4px 8px; font-family:var(--font); font-size:10px; outline:none; }
- .ws-add input:focus { border-color:var(--accent); }
- .ws-add button { font-size:10px; }
- /* Directory browser in workspace dropdown */
- .ws-browse { border-top:1px solid var(--border); }
- .ws-browse-header { display:flex; align-items:center; gap:4px; padding:6px 8px; background:var(--bg); border-bottom:1px solid var(--border); }
- .ws-browse-header .browse-up { background:none; border:1px solid var(--border); color:var(--text2); border-radius:3px; cursor:pointer; font-size:11px; padding:2px 6px; font-family:var(--font); }
- .ws-browse-header .browse-up:hover { color:var(--text); border-color:var(--text2); }
- .ws-browse-header .browse-path { flex:1; font-size:9px; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; }
- .ws-browse-header .browse-select { background:var(--accent); color:#fff; border:none; border-radius:3px; cursor:pointer; font-size:9px; padding:2px 8px; font-family:var(--font); font-weight:600; }
- .ws-browse-header .browse-select:hover { opacity:0.85; }
- .ws-browse-list { max-height:180px; overflow-y:auto; }
- .ws-browse-item { display:flex; align-items:center; gap:6px; padding:4px 10px; cursor:pointer; font-size:11px; border-bottom:1px solid rgba(48,54,61,0.5); }
- .ws-browse-item:hover { background:var(--bg3); }
- .ws-browse-item.is-vl { background:rgba(88,166,255,0.05); }
- .ws-browse-item .dir-icon { font-size:12px; flex-shrink:0; }
- .ws-browse-item .dir-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .ws-browse-item .dir-vl { font-size:8px; color:var(--accent); background:rgba(88,166,255,0.12); padding:1px 4px; border-radius:2px; flex-shrink:0; }
- /* Chat input area (enhanced with image + mention) */
- .chat-input-area { border-top:1px solid var(--border); background:linear-gradient(180deg, rgba(13,17,23,0.98) 0%, rgba(20,25,33,0.98) 100%); padding:8px 10px 10px; }
- .chat-attachments { display:flex; gap:6px; padding:0 0 6px; flex-wrap:wrap; }
- .chat-attachments:empty { display:none; }
- .chat-attach-item { background:rgba(88,166,255,0.08); border:1px solid rgba(88,166,255,0.22); border-radius:999px; padding:4px 8px; font-size:10px; display:flex; align-items:center; gap:6px; }
- .chat-attach-item img { width:24px; height:24px; object-fit:cover; border-radius:2px; }
- .chat-attach-item .remove { cursor:pointer; color:var(--red); font-size:10px; }
- .plan-mode-bar { display:flex; align-items:center; gap:8px; padding:6px 10px; margin-bottom:6px; background:rgba(28,34,44,0.95); border:1px solid var(--border); border-radius:9px; font-size:11px; }
- .plan-mode-label { color:var(--yellow, #e2b714); font-weight:600; flex:1; }
- .plan-approve-btn { background:var(--green, #3fb950); color:#fff; border:none; padding:4px 12px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; border-radius:3px; }
- .plan-approve-btn:hover { opacity:0.85; }
- .plan-cancel-btn { background:var(--red, #f85149); color:#fff; border:none; padding:4px 12px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; border-radius:3px; }
- .plan-cancel-btn:hover { opacity:0.85; }
- #planModeToggle.active { color:var(--yellow, #e2b714); }
- .chat-input-row { display:flex; align-items:flex-end; gap:6px; background:linear-gradient(180deg, #121922 0%, #0f151d 100%); border:1px solid #2a3441; border-radius:12px; padding:6px; box-shadow:inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 24px rgba(0,0,0,0.18); }
- .chat-input-row:focus-within { border-color:rgba(88,166,255,0.75); box-shadow:0 0 0 1px rgba(88,166,255,0.2), 0 12px 28px rgba(0,0,0,0.22); }
- .chat-input-row textarea { flex:1; background:transparent; border:none; color:var(--text); padding:8px 2px 8px 0; font-family:var(--font); font-size:12.5px; outline:none; min-height:42px; max-height:180px; resize:none; overflow-y:auto; line-height:1.55; }
- .chat-input-row textarea::placeholder { color:#778291; }
- .chat-input-row .input-btn { width:34px; height:34px; background:none; border:none; color:var(--text2); cursor:pointer; border-radius:8px; padding:0; font-size:15px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
- .chat-input-row .input-btn:hover { color:var(--text); background:rgba(255,255,255,0.05); }
- .chat-input-row button.send-btn { background:linear-gradient(180deg, #58a6ff 0%, #3e8ae0 100%); color:#fff; border:none; min-width:74px; padding:10px 16px; cursor:pointer; font-family:var(--font); font-size:12px; font-weight:700; border-radius:10px; margin:0; align-self:stretch; box-shadow:0 8px 20px rgba(32,96,176,0.25); }
- .chat-input-row button.send-btn:disabled { opacity:0.4; cursor:default; }
- .chat-input-row button.stop-btn { background:linear-gradient(180deg, #f85149 0%, #d83b34 100%); color:#fff; border:none; min-width:74px; padding:8px 14px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:700; border-radius:10px; align-self:stretch; }
- .chat-input-row button.stop-btn:hover { opacity:0.85; }
- .settings-provider-switch { display:flex; gap:8px; margin-bottom:8px; }
- .settings-provider-option { flex:1; display:flex; align-items:flex-start; gap:6px; padding:8px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); cursor:pointer; }
- .settings-provider-option input { margin-top:2px; }
- .settings-provider-copy { display:flex; flex-direction:column; gap:2px; }
- .settings-provider-copy strong { font-size:11px; color:var(--text); }
- .settings-provider-copy span { font-size:10px; color:var(--text2); line-height:1.4; }
- .settings-provider-hint { font-size:10px; color:var(--text2); margin:-2px 0 10px; line-height:1.5; }
- .settings-doc-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; margin-top:8px; }
- .settings-doc-card { display:flex; flex-direction:column; gap:4px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); }
- .settings-doc-card input { margin-top:2px; }
- .settings-doc-hint { font-size:10px; color:var(--text2); line-height:1.5; margin-top:8px; }
- .mode-tab[data-mode="docs"] { color:var(--yellow); }
- .mode-tab[data-mode="docs"].active { color:var(--yellow); border-bottom-color:var(--yellow); }
- .chat-status-bar { display:flex; align-items:center; gap:6px; padding:4px 10px; margin-bottom:6px; background:rgba(20,27,35,0.95); border:1px solid var(--border); border-radius:9px; font-size:9px; color:var(--text2); min-height:24px; }
- .chat-status-bar .cs-dot { width:6px; height:6px; border-radius:50%; background:var(--orange); animation:csPulse 1.2s ease-in-out infinite; flex-shrink:0; }
- @keyframes csPulse { 0%,100%{opacity:.4;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
- .chat-status-bar .cs-phase { color:var(--text); font-weight:500; }
- .chat-status-bar .cs-detail { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px; }
- .chat-status-bar .cs-elapsed { margin-left:auto; color:var(--text2); }
- .chat-status-bar .cs-kill { background:var(--red); color:#fff; border:none; padding:1px 6px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:8px; font-weight:700; margin-left:4px; }
- .chat-status-bar .cs-kill:hover { opacity:0.8; }
- /* @-mention autocomplete dropdown */
- .mention-dropdown { display:none; position:absolute; bottom:100%; left:0; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px 6px 0 0; max-height:200px; overflow-y:auto; z-index:60; }
- .mention-dropdown.open { display:block; }
- .mention-item { padding:5px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:6px; }
- .mention-item:hover,.mention-item.selected { background:var(--bg3); }
- .mention-item .m-type { font-size:8px; padding:1px 3px; border-radius:2px; font-weight:600; }
- /* Apply button for code blocks */
- .code-apply { position:absolute; top:4px; right:4px; background:var(--accent); color:#fff; border:none; border-radius:3px; padding:2px 8px; font-size:9px; cursor:pointer; font-family:var(--font); opacity:0.8; }
- .code-apply:hover { opacity:1; }
- .msg.assistant .content-text pre { position:relative; }
- /* Inline Diff */
- .diff-block { margin:6px 0; border:1px solid var(--border); border-radius:6px; overflow:hidden; font-size:11px; }
- .diff-header { display:flex; justify-content:space-between; align-items:center; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
- .diff-header .diff-file { color:var(--accent); font-weight:600; }
- .diff-actions { display:flex; gap:4px; }
- .diff-actions button { padding:2px 8px; border-radius:3px; font-size:9px; cursor:pointer; font-family:var(--font); border:none; }
- .diff-accept { background:var(--green); color:#fff; }
- .diff-reject { background:var(--red); color:#fff; }
- .diff-body { max-height:200px; overflow-y:auto; }
- .diff-line { padding:0 8px; font-family:var(--font); white-space:pre; }
- .diff-add { background:#3fb95018; color:var(--green); }
- .diff-add::before { content:'+'; margin-right:6px; }
- .diff-del { background:#f8514918; color:var(--red); text-decoration:line-through; }
- .diff-del::before { content:'-'; margin-right:6px; }
- .diff-ctx { color:var(--text2); }
- /* Conversation tabs */
- .conv-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:28px; align-items:center; position:relative; }
- .conv-tab { padding:5px 12px; cursor:pointer; color:var(--text2); font-size:10px; border-bottom:2px solid transparent; white-space:nowrap; display:flex; align-items:center; gap:4px; }
- .conv-tab.active { color:var(--text); border-bottom-color:var(--accent); }
- .conv-tab .conv-close { font-size:8px; opacity:0.5; cursor:pointer; margin-left:4px; }
- .conv-tab .conv-close:hover { opacity:1; color:var(--red); }
- .conv-new { padding:5px 8px; cursor:pointer; color:var(--text2); font-size:12px; border:none; background:none; }
- .conv-new:hover { color:var(--accent); }
- /* History dropdown */
- .conv-tabs .tab-spacer { flex:1; min-width:8px; }
- .conv-history-btn { padding:4px 8px; cursor:pointer; color:var(--text2); font-size:10px; border:none; background:none; opacity:0.7; }
- .conv-history-btn:hover { opacity:1; color:var(--accent); }
- .history-panel { display:none; position:absolute; top:100%; right:0; width:360px; max-height:420px; background:var(--bg2); border:1px solid var(--border); border-radius:0 0 8px 8px; box-shadow:0 6px 20px rgba(0,0,0,0.5); z-index:80; overflow:hidden; flex-direction:column; }
- .history-panel.open { display:flex; }
- .history-search { padding:8px; border-bottom:1px solid var(--border); }
- .history-search input { width:100%; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-family:var(--font); font-size:11px; outline:none; }
- .history-search input:focus { border-color:var(--accent); }
- .history-list { flex:1; overflow-y:auto; padding:4px 0; }
- .history-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); }
- .history-item:hover { background:var(--bg3); }
- .history-item:last-child { border-bottom:none; }
- .history-item .hi-title { font-size:11px; color:var(--text); font-weight:500; margin-bottom:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- .history-item .hi-meta { display:flex; align-items:center; gap:6px; font-size:9px; color:var(--text2); }
- .history-item .hi-tag { background:var(--bg3); border:1px solid var(--border); border-radius:3px; padding:1px 5px; font-size:8px; color:var(--accent); }
- .history-item .hi-summary { font-size:10px; color:var(--text2); margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- .history-empty { padding:20px; text-align:center; color:var(--text2); font-size:11px; }
- /* Image in user message */
- .msg-images { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
- .msg-images img { max-width:120px; max-height:80px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
- /* Auto-screenshots from tests */
- .msg-screenshots { display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; padding:6px; background:var(--bg2); border-radius:6px; border:1px solid var(--border); }
- .msg-screenshots .ss-item { position:relative; }
- .msg-screenshots img { max-width:200px; max-height:140px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
- .msg-screenshots img:hover { border-color:var(--accent); }
- .msg-screenshots .ss-label { position:absolute; bottom:4px; left:4px; background:rgba(0,0,0,0.7); color:#fff; font-size:9px; padding:1px 5px; border-radius:3px; }
- .debug-entry .debug-screenshots { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
- .debug-entry .debug-screenshots img { max-width:160px; max-height:100px; border-radius:3px; border:1px solid var(--border); cursor:pointer; }
- /* AskUserQuestion widget */
- .ask-user-widget { margin:6px 0; background:var(--bg2); border:1px solid var(--accent); border-radius:8px; padding:10px 14px; }
- .ask-user-widget .ask-question { color:var(--text); font-size:12px; font-weight:600; margin-bottom:8px; }
- .ask-user-option { display:flex; align-items:flex-start; gap:8px; padding:6px 10px; margin:3px 0; border-radius:6px; cursor:pointer; border:1px solid var(--border); transition:all 0.15s; }
- .ask-user-option:hover { border-color:var(--accent); background:rgba(88,166,255,0.08); }
- .ask-user-option.selected { border-color:var(--accent); background:rgba(88,166,255,0.15); }
- .ask-user-option input[type=radio],.ask-user-option input[type=checkbox] { margin-top:3px; accent-color:var(--accent); }
- .ask-user-option .opt-label { font-size:11px; color:var(--text); font-weight:600; }
- .ask-user-option .opt-desc { font-size:10px; color:var(--text2); margin-top:1px; }
- .ask-user-other { margin-top:6px; display:flex; gap:6px; }
- .ask-user-other input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-family:var(--font); font-size:11px; outline:none; }
- .ask-user-submit { margin-top:8px; display:flex; justify-content:flex-end; }
- .ask-user-submit button { background:var(--accent); color:#fff; border:none; border-radius:4px; padding:5px 14px; font-family:var(--font); font-size:11px; cursor:pointer; }
- /* Skill command palette */
- .skill-palette { display:none; position:absolute; bottom:100%; left:0; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px 6px 0 0; max-height:220px; overflow-y:auto; z-index:65; }
- .skill-palette.open { display:block; }
- .skill-item { padding:6px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:8px; }
- .skill-item:hover,.skill-item.selected { background:var(--bg3); }
- .skill-item .sk-name { color:var(--accent); font-weight:600; }
- .skill-item .sk-desc { color:var(--text2); font-size:10px; }
- /* Search bar in chat */
- .chat-search { display:none; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
- .chat-search.open { display:flex; gap:6px; align-items:center; }
- .chat-search input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:4px 8px; font-family:var(--font); font-size:10px; outline:none; }
- .chat-search .search-count { font-size:10px; color:var(--text2); }
- /* Scrollbar */
- ::-webkit-scrollbar { width:5px; }
- ::-webkit-scrollbar-track { background:transparent; }
- ::-webkit-scrollbar-thumb { background:var(--bg3); border-radius:3px; }
- </style>
- </head>
- <body>
- <!-- Landing / Login page (shown before entering IDE) -->
- <div class="landing-overlay" id="landingOverlay">
- <div class="landing-shell">
- <div class="landing-box">
- <div class="landing-brand">
- <img src="/assets/vlcode-lite-icon.svg?v=20260315" alt="VL-Code logo">
- <div>
- <h1>VL-Code</h1>
- <div class="landing-sub">AI Programming IDE for VL Language · Powered by Claude</div>
- </div>
- </div>
- <!-- Section 1: VL Cloud Login -->
- <div class="landing-section">
- <h3>☁ VL Cloud Platform <span class="ls-badge recommended">Recommended</span></h3>
- <div class="landing-tabs">
- <div class="landing-tab active" data-ltab="enterprise" onclick="switchLandingTab('enterprise')">Enterprise</div>
- <div class="landing-tab" data-ltab="google" onclick="switchLandingTab('google')">Google</div>
- <div class="landing-tab" data-ltab="token" onclick="switchLandingTab('token')">Token</div>
- </div>
- <!-- Enterprise -->
- <div class="landing-tab-panel active" id="ltEnterprise">
- <input type="text" id="landingUsername" placeholder="Email" autocomplete="username" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
- <input type="password" id="landingPassword" placeholder="Password" autocomplete="current-password" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
- <input type="text" id="landingCompany" placeholder="Company name (e.g. ivx)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
- <button class="hdr-btn hdr-btn-primary" onclick="doLandingEnterpriseLogin()" style="margin-top:4px;">Login & Enter IDE</button>
- <div id="landingLoginError" style="display:none;color:var(--red);font-size:10px;margin-top:6px;"></div>
- </div>
- <!-- Google -->
- <div class="landing-tab-panel" id="ltGoogle">
- <div style="text-align:center;padding:8px 0;">
- <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Google Login in Browser</button>
- <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
- After Google login, copy your <code style="color:var(--accent)">ih5bearer</code> cookie<br>and paste it in the Token tab.
- </div>
- </div>
- </div>
- <!-- Token -->
- <div class="landing-tab-panel" id="ltToken">
- <input type="text" id="landingDirectCookie" placeholder="ih5bearer token (eyJhbGci...)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:10px;">
- <button class="hdr-btn hdr-btn-primary" onclick="doLandingTokenLogin()" style="margin-top:4px;">Connect & Enter IDE</button>
- </div>
- </div>
- <!-- Section 2: Claude API Key (optional) -->
- <div class="landing-section">
- <h3>🤖 Claude API Key <span class="ls-badge optional">Optional</span></h3>
- <div style="font-size:10px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
- If you have a Claude CLI Team subscription, the API Key is <strong style="color:var(--green)">not needed</strong> — CLI will be used automatically.<br>
- Only fill this if you don't have a CLI subscription.
- </div>
- <div id="landingCliStatus" style="font-size:11px;margin-bottom:8px;display:none;"></div>
- <input type="password" id="landingApiKey" placeholder="sk-ant-api03-..." style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
- </div>
- <!-- Enter IDE -->
- <button class="hdr-btn hdr-btn-primary" onclick="enterIDE()" style="width:100%;padding:10px;font-size:13px;margin-top:4px;">Enter VL-Code IDE</button>
- <div class="landing-skip">
- <a onclick="enterIDE()">Skip login, enter IDE directly</a>
- </div>
- <div style="font-size:9px;color:var(--text2);margin-top:12px;">Settings are saved locally and never shared.</div>
- </div>
- <div class="landing-docs">
- <div class="landing-docs-head">
- <div>
- <h2>Official DocCenter</h2>
- <div class="landing-docs-copy">Search official docs, confirm the slot/path, then copy the numeric <code>Doc ID</code> into VLCode after login. The page also shows the matching ref and viewer link for debugging.</div>
- </div>
- <button class="hdr-btn" onclick="refreshLandingDocsFrame()">Refresh</button>
- </div>
- <div class="landing-docs-note">Workflow execution resolves official specs and workflow prompts by <code>Doc ID</code>. Slots stay as reserved aliases; viewer links and <code>Doc Ref</code> are only debug helpers.</div>
- <iframe id="landingDocsFrame" class="landing-docs-frame" src="/doc-center.html?embed=landing" title="VLCode DocCenter"></iframe>
- </div>
- </div>
- </div>
- <header>
- <h1 class="app-brand"><img src="/assets/vlcode-lite-icon.svg?v=20260315" alt="VLCode Lite icon"><span>VLCode Lite <span id="appVersion" style="font-size:11px;font-weight:400;color:var(--text2);vertical-align:middle;"></span></span></h1>
- <div class="ws-tabs" id="wsTabs"></div>
- <div style="position:relative;">
- <div class="ws-popover" id="wsPopover" style="min-width:340px;">
- <div class="ws-section" style="display:flex;justify-content:space-between;align-items:center;">
- <span>Workspaces</span>
- <div style="display:flex;gap:4px;">
- <button class="hdr-btn" id="wsOpenFolderBtn" onclick="event.stopPropagation();openWorkspacePicker()" style="display:none;font-size:9px;padding:2px 8px;">Open Folder...</button>
- <button class="hdr-btn" onclick="event.stopPropagation();closeWorkspace()" style="font-size:9px;padding:2px 8px;color:var(--text2);">Close</button>
- <button class="hdr-btn" onclick="event.stopPropagation();toggleNewProjectForm()" style="font-size:9px;padding:2px 8px;">+ New Project</button>
- </div>
- </div>
- <div id="wsNewProjectForm" style="display:none;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg2);">
- <div style="font-size:10px;color:var(--text2);margin-bottom:6px;">Create New VL Project</div>
- <div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;">
- <span style="font-size:9px;color:var(--text2);white-space:nowrap;">Location:</span>
- <input type="text" id="newProjectLocation" placeholder="/path/to/parent" style="flex:1;font-size:10px;padding:3px 6px;color:var(--accent);background:var(--bg1);border:1px solid var(--border);border-radius:3px;">
- <button class="hdr-btn" id="newProjectLocationPickBtn" onclick="event.stopPropagation();pickNewProjectLocation()" style="display:none;font-size:9px;padding:3px 8px;">Pick</button>
- </div>
- <div style="display:flex;gap:4px;">
- <input type="text" id="newProjectName" placeholder="Project name..." style="flex:1;font-size:11px;padding:4px 6px;" onkeydown="if(event.key==='Enter')createNewProject()">
- <button class="hdr-btn hdr-btn-primary" onclick="createNewProject()" style="font-size:10px;padding:4px 10px;">Create</button>
- </div>
- <div id="newProjectError" style="font-size:9px;color:var(--red);margin-top:4px;display:none;"></div>
- </div>
- <div id="wsList"></div>
- <div class="ws-browse">
- <div class="ws-browse-header">
- <button class="browse-up" onclick="event.stopPropagation();browseDirUp()" title="Go to parent directory">▲</button>
- <span class="browse-path" id="browsePath">~</span>
- <button class="browse-select" onclick="event.stopPropagation();selectBrowseDir()" title="Open this directory as workspace">Select</button>
- </div>
- <div class="ws-browse-list" id="browseList"></div>
- </div>
- <div class="ws-add">
- <input type="text" id="wsAddPath" placeholder="Or type path..." onkeydown="if(event.key==='Enter')addWorkspace()">
- <button class="hdr-btn" onclick="addWorkspace()" style="font-size:10px;padding:3px 8px;">Go</button>
- </div>
- </div>
- </div>
- <div class="spacer"></div>
- <span class="info" id="projectInfo" style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
- <div class="ctx-bar ctx-tooltip">
- <span class="info" id="ctxLabel">0%</span>
- <div class="bar"><div class="bar-fill" id="ctxBar" style="width:0%"></div></div>
- <div class="ctx-detail" id="ctxDetail">Context: 0 / 200K tokens</div>
- </div>
- <div class="wf-selector" style="position:relative;display:none;">
- <button class="hdr-btn" onclick="toggleWorkflowPanel()" id="wfSelectorBtn" title="Select codegen workflow">
- <span id="wfSelectorLabel">Parallel</span> <span style="font-size:9px;opacity:0.6">▼</span>
- </button>
- <div class="wf-dropdown" id="wfDropdown">
- <div style="font-size:10px;color:var(--text2);padding:8px 10px;border-bottom:1px solid var(--border);">Codegen Workflow</div>
- <div id="wfCodegenOptions"></div>
- <div style="font-size:10px;color:var(--text2);padding:8px 10px;border-bottom:1px solid var(--border);border-top:1px solid var(--border);">Adjustment Workflow</div>
- <div id="wfAdjustOptions"></div>
- <div style="font-size:9px;color:var(--text2);padding:6px 10px;border-top:1px solid var(--border);">
- <div style="cursor:pointer;" onclick="toggleWfAllList()">All workflows <span id="wfAllToggle">▶</span></div>
- </div>
- <div id="wfList" style="max-height:120px;overflow-y:auto;display:none;"></div>
- </div>
- </div>
- <button class="hdr-btn" id="compileBtn" onclick="compileProject()" title="Compile & Preview">▶ Compile</button>
- <!-- Mode toggle removed (VLCode Lite — no fleet management) -->
- <span class="llm-badge cli" id="llmBadge" title="LLM Provider" onclick="openSettings()">CLI</span>
- <div class="auth-status" id="authStatus" onclick="onAuthStatusClick()" title="Cloud Platform Account">
- <span class="auth-dot" id="authDot"></span>
- <span class="auth-label" id="authLabel">Not logged in</span>
- </div>
- <button class="hdr-btn" onclick="switchMode('docs')" title="Documentation">Docs</button>
- <button class="hdr-btn" id="cloudBtn" onclick="toggleCloudPanel()" title="Cloud Platform">☁ Cloud</button>
- <button class="hdr-btn" onclick="restartBackend()" title="Restart Backend" id="restartBtn">↻</button>
- <button class="hdr-btn" onclick="openSettings()" title="Settings">⚙</button>
- </header>
- <main>
- <div class="sidebar">
- <h3 style="display:flex;justify-content:space-between;align-items:center;">Files <span id="sidebarProjectName" style="font-weight:400;font-size:9px;color:var(--accent);cursor:pointer;text-transform:none;letter-spacing:0;" onclick="openFolderInFinder()" title="Open in Finder"></span></h3>
- <div class="sidebar-actions">
- <button class="sa-btn" onclick="importFiles()" title="Import files into project"><span class="sa-icon">+</span>Import</button>
- <button class="sa-btn" onclick="importZipAsProject()" title="Create new project from ZIP"><span class="sa-icon">▢</span>ZIP</button>
- <button class="sa-btn" onclick="exportAll()" title="Export project with all files"><span class="sa-icon">↧</span>Export</button>
- <button class="sa-btn" onclick="exportVLOnly()" title="Export VL files only"><span class="sa-icon">↧</span>VL</button>
- <button class="sa-btn" id="toggleInternalFilesBtn" onclick="toggleInternalFiles()" title="Show internal files and generated artifacts"><span class="sa-icon">⋮</span>Internal</button>
- <button class="sa-btn sa-danger" onclick="clearAllFiles()" title="Remove all files"><span class="sa-icon">×</span>Clear</button>
- </div>
- <div class="file-tree" id="fileTree"
- ondragover="handleFileTreeDragOver(event)"
- ondragleave="handleFileTreeDragLeave(event)"
- ondrop="handleFileTreeDrop(event)">
- </div>
- <div id="sidebarDropOverlay" style="display:none;position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(99,102,241,0.15);border:2px dashed var(--accent);border-radius:6px;z-index:50;pointer-events:none;">
- <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:var(--accent);font-size:12px;font-weight:600;">Drop files here</div>
- </div>
- <div class="project-config" id="projectConfigPanel">
- <h4 class="pc-header" onclick="$('pcFiles').style.display=$('pcFiles').style.display==='none'?'block':'none'">Project <span style="float:right;font-size:8px;">▼</span></h4>
- <div class="pc-files" id="pcFiles">
- <div class="pc-file" onclick="openFile('.vl-code/VL.md')" title="Project instructions for AI">VL.md</div>
- </div>
- </div>
- <div class="project-config" id="docIdConfigPanel">
- <h4 class="pc-header" onclick="toggleDocIdConfigPanel()" style="display:flex;justify-content:space-between;align-items:center;">
- Official Doc IDs
- <span style="display:flex;gap:4px;align-items:center;">
- <button class="pc-sync-btn" onclick="event.stopPropagation();switchMode('docs')" title="Open embedded DocCenter">Docs</button>
- <button class="pc-sync-btn" onclick="event.stopPropagation();saveDocIdConfigPanel()" title="Save document IDs">Save</button>
- <span style="font-size:8px;">▼</span>
- </span>
- </h4>
- <div id="docIdConfigBody">
- <div class="doc-id-panel-note">这里真正填写的是 <code>Doc ID</code>。也支持直接粘贴 <code>vl://doc/<id></code> 或 <code>/doc-center.html?docId=<id></code>,保存时会自动归一成 Doc ID。Slot 只保留给 runtime 内部映射。</div>
- <div class="doc-id-section-title">Core Runtime</div>
- <div class="doc-id-grid" id="docIdCoreGrid"></div>
- <div class="doc-id-section-title doc-id-section-toggle" onclick="toggleDocWorkflowGrid()">
- <span>Workflow Docs</span>
- <span id="docWorkflowToggle">▼</span>
- </div>
- <div class="doc-id-grid" id="docIdWorkflowGrid" style="display:flex;"></div>
- <div class="doc-id-section-title">Locked By Tooling</div>
- <div class="doc-id-grid" id="docIdLockedGrid"></div>
- </div>
- </div>
- <div class="project-config" id="vlDocsPanel">
- <h4 class="pc-header" onclick="$('vlDocsList').style.display=$('vlDocsList').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
- VL Reference Docs
- <span style="display:flex;gap:4px;align-items:center;">
- <button class="pc-sync-btn" onclick="event.stopPropagation();syncVLDocs()" title="Sync docs from DocCenter">↻</button>
- <span style="font-size:8px;">▼</span>
- </span>
- </h4>
- <div class="pc-files" id="vlDocsList" style="display:none;"></div>
- </div>
- <div class="preview-urls" id="previewUrlsPanel" style="display:none">
- <h4>App Previews</h4>
- <div id="previewUrlsList"></div>
- </div>
- <div class="project-config" id="cloudPanel" style="display:none;">
- <h4 class="pc-header" onclick="$('cloudPanelBody').style.display=$('cloudPanelBody').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
- Cloud Platform
- <span style="display:flex;gap:4px;align-items:center;">
- <span class="cloud-dot" id="cloudDot"></span>
- <span style="font-size:8px;">▼</span>
- </span>
- </h4>
- <div id="cloudPanelBody">
- <div id="cloudLoginPrompt" class="cloud-section">
- <div style="padding:6px 12px;font-size:10px;color:var(--text2);">Not connected</div>
- <button class="sa-btn" onclick="openCloudLogin()" style="margin:0 12px 8px;width:calc(100% - 24px);">Login</button>
- </div>
- <div id="cloudConnected" class="cloud-section" style="display:none;">
- <div class="cloud-user" id="cloudUserInfo"></div>
- <div class="cloud-actions">
- <button class="sa-btn" onclick="cloudSyncPush()" title="Push local files to cloud workspace">Push</button>
- <button class="sa-btn" onclick="cloudSyncPull()" title="Pull cloud files to local">Pull</button>
- <button class="sa-btn" onclick="cloudCompile()" title="Sync + Compile via cloud workspace">Compile</button>
- </div>
- <div class="cloud-gid">
- <div style="display:flex;align-items:center;justify-content:space-between;padding:0 12px;margin-bottom:2px;">
- <label style="font-size:9px;color:var(--text2);">Workspace GID</label>
- <button class="sa-btn" onclick="createCloudProject()" style="font-size:8px;padding:1px 6px;" title="Create a new cloud workspace and get GID">+ New</button>
- </div>
- <input type="text" id="cloudGid" placeholder="select below or enter manually" style="font-size:10px;margin:0 12px 4px;width:calc(100% - 24px);background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:3px 6px;">
- </div>
- <div id="cloudAppsList" style="max-height:120px;overflow-y:auto;"></div>
- <button class="sa-btn sa-small" onclick="cloudLogout()" style="margin:4px 12px 8px;font-size:9px;color:var(--red);">Logout</button>
- </div>
- <div class="cloud-status" id="cloudSyncStatus" style="display:none;"></div>
- </div>
- </div>
- </div>
- <div class="content">
- <div class="panels">
- <div class="editor-panel">
- <div class="mode-tabs" id="modeTabs">
- <div class="mode-tab active" data-mode="code" onclick="switchMode('code')">Code</div>
- <div class="mode-tab" data-mode="meta" onclick="switchMode('meta')">Map <span id="mapReadyDot" style="display:none;width:6px;height:6px;border-radius:50%;background:var(--green,#4caf50);margin-left:3px;vertical-align:middle;display:none;"></span></div>
- <div class="mode-tab" data-mode="flow" onclick="switchMode('flow')">Flow</div>
- <div class="mode-tab" data-mode="docs" onclick="switchMode('docs')">Docs</div>
- <div class="mode-tab" data-mode="preview" id="previewModeTab" onclick="switchMode('preview')" style="display:none">Preview</div>
- </div>
- <div class="preview-bar" id="previewBar" style="display:none;">
- <select id="previewAppSelect" onchange="loadPreviewApp()"></select>
- <span class="preview-url" id="previewUrlLabel"></span>
- <button class="preview-btn" onclick="refreshPreview()">Refresh</button>
- <button class="preview-btn" onclick="openPreviewExternal()">Open External</button>
- </div>
- <div class="flow-toolbar" id="flowToolbar" style="display:none;">
- <div class="flow-sub-tabs">
- <div class="flow-sub-tab active" data-flow="generate" onclick="switchFlowTab('generate')">Generate</div>
- <div class="flow-sub-tab" data-flow="adjust" onclick="switchFlowTab('adjust')">Adjust</div>
- <div class="flow-sub-tab" data-flow="autotest" onclick="switchFlowTab('autotest')" style="color:var(--orange)">Autotest</div>
- </div>
- <div class="flow-actions">
- <select id="flowWfSelect" onchange="onFlowWfSelectChange(this.value)" title="Select workflow">
- <option value="">-- Select Workflow --</option>
- </select>
- <button class="flow-btn" onclick="importFlowJson()" title="Load workflow JSON from file">Load JSON</button>
- <input type="file" id="flowJsonInput" accept=".json" style="display:none">
- <button class="flow-btn flow-btn-run" id="flowRunBtn" onclick="runFlowWorkflow()" title="Execute selected workflow">▶ Run</button>
- <span class="flow-run-status" id="flowRunStatus"></span>
- </div>
- </div>
- <!-- Workflow picker for Generate / Adjust sub-tabs -->
- <div class="flow-wf-list" id="flowWfList"></div>
- <!-- AutoTest 3-layer workflow hierarchy (shown only in autotest tab) -->
- <div class="at-wf-list" id="atWfList"></div>
- <div class="editor-tabs" id="editorTabs"></div>
- <div class="editor-area">
- <div id="cmEditorWrap" style="display:none;width:100%;height:100%;"></div>
- <textarea id="editor" spellcheck="false" style="display:none"></textarea>
- <div class="code-preview" id="codePreview"><pre></pre></div>
- <div class="md-preview" id="mdPreview"></div>
- <div class="iframe-container" id="iframeContainer"></div>
- <div class="editor-placeholder" id="editorPlaceholder">
- Click a file in the tree to view & edit<br>
- <span style="font-size:11px;color:var(--text2)">Use Load button to open a project folder</span>
- </div>
- </div>
- </div>
- <!-- Detail Panel — detailed logs without consuming AI context -->
- <div class="detail-panel" id="detailPanel">
- <div class="detail-header" id="detailHeader">
- <span class="dh-title">Detail Log</span>
- <div style="display:flex;gap:4px;align-items:center;">
- <span id="detailCount" style="font-size:8px;color:var(--text2);"></span>
- <button onclick="clearDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:10px;" title="Clear">× Clear</button>
- <button onclick="toggleDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:12px;" title="Close">×</button>
- </div>
- </div>
- <div class="detail-body" id="detailBody"></div>
- </div>
- <!-- Debug panel removed — replaced by Detail Log -->
- <div class="chat-panel" id="chatPanel">
- <div class="chat-resize-handle" id="chatResizeHandle"></div>
- <div class="chat-header" onclick="if(document.querySelector('.chat-panel').classList.contains('collapsed'))toggleChatCollapse()">
- <span>AI Assistant</span>
- <div style="display:flex;align-items:center;gap:6px;">
- <span id="chatModel"></span>
- <button class="chat-collapse-btn" onclick="event.stopPropagation();toggleChatCollapse()" title="Collapse/Expand chat">◀</button>
- </div>
- </div>
- <div class="chat-actions" id="chatActions">
- <div class="chat-action-group">
- <button class="ca-btn ca-primary" onclick="sendSkillCmd('validate-all')">Validate</button>
- <button class="ca-btn ca-primary" onclick="sendSkillCmd('deploy')">Deploy</button>
- <button class="ca-btn ca-log" onclick="toggleDetailPanel()" title="Toggle Detail Log panel">Logs</button>
- </div>
- <div class="chat-action-group">
- <div class="ca-menu" id="chatMoreMenu">
- <button class="ca-btn" id="chatMoreBtn" onclick="toggleChatMoreMenu(event)">More ▾</button>
- <div class="ca-menu-panel">
- <button class="ca-menu-item menu-accent" onclick="chatMenuAction('blueprint')">Blueprint</button>
- <button class="ca-menu-item" onclick="chatMenuAction('search')">Search Chat</button>
- <button class="ca-menu-item" id="compactMenuItem" onclick="chatMenuAction('compact')">Compact Mode</button>
- <button class="ca-menu-item menu-log" onclick="chatMenuAction('settings')">Settings</button>
- </div>
- </div>
- </div>
- </div>
- <div class="conv-tabs" id="convTabs">
- <div class="conv-tab active" data-conv="0">Chat 1</div>
- <button class="conv-new" onclick="newConversation()" title="New conversation">+</button>
- </div>
- <div class="chat-search" id="chatSearch">
- <input id="chatSearchInput" placeholder="Search conversation..." oninput="searchConversation(this.value)">
- <span class="search-count" id="searchCount"></span>
- <button class="input-btn" onclick="closeChatSearch()" style="font-size:12px">×</button>
- </div>
- <div class="chat-messages" id="chatMessages"></div>
- <div class="chat-input-area" style="position:relative;">
- <div class="skill-palette" id="skillPalette"></div>
- <div class="mention-dropdown" id="mentionDropdown"></div>
- <div class="chat-attachments" id="chatAttachments"></div>
- <div class="chat-status-bar" id="chatStatusBar" style="display:none">
- <span class="cs-dot"></span>
- <span class="cs-phase" id="csPhase"></span>
- <span class="cs-detail" id="csDetail"></span>
- <span class="cs-elapsed" id="csElapsed"></span>
- <button class="cs-kill" onclick="stopExecution()" title="Kill all running tasks">STOP</button>
- </div>
- <div class="plan-mode-bar" id="planModeBar" style="display:none">
- <span class="plan-mode-label">🔎 Explore Mode (read-only)</span>
- <button class="plan-approve-btn" id="planApproveBtn" onclick="approvePlan()" style="display:none">✓ Approve</button>
- <button class="plan-cancel-btn" id="planCancelBtn" onclick="cancelPlan()">✗ Cancel</button>
- </div>
- <div class="chat-input-row">
- <button class="input-btn" onclick="$('imageInput').click()" title="Attach image">📷</button>
- <button class="input-btn" id="planModeToggle" onclick="togglePlanMode()" title="Toggle Plan Mode (explore before implement)">🔎</button>
- <textarea id="chatInput" rows="1" placeholder="Describe changes, @mention files, /skill..." autocomplete="off"></textarea>
- <button class="send-btn" id="chatSend" onclick="sendMessage()">Send</button>
- <button class="stop-btn" id="chatStop" onclick="stopExecution()" style="display:none">Stop</button>
- </div>
- </div>
- <input type="file" id="imageInput" accept="image/*" multiple style="display:none">
- </div>
- </div>
- </div>
- </main>
- <div class="bottom-bar">
- <div class="status"><div class="dot dot-green"></div> <span id="statusText">Ready</span></div>
- <span id="fileCount"></span>
- <span id="currentFile"></span>
- <span style="margin-left:auto" id="modelLabel"></span>
- </div>
- <!-- Drop overlay -->
- <div class="drop-overlay" id="dropOverlay" onclick="this.classList.remove('active');dragCounter=0;">
- <div class="drop-msg">
- <h2>Drop Files or Folder</h2>
- <p>Code files, VL files, JSON, ZIP — preserves folder structure</p>
- </div>
- </div>
- <input type="file" id="folderInput" webkitdirectory directory multiple style="display:none">
- <input type="file" id="zipInput" accept=".zip" style="display:none">
- <!-- File context menu -->
- <div class="ctx-menu" id="fileCtxMenu">
- <div class="ctx-menu-item" onclick="ctxOpenFile()">Open</div>
- <div class="ctx-menu-sep"></div>
- <div class="ctx-menu-item danger" onclick="ctxDeleteFile()">Delete File</div>
- </div>
- <!-- Step card context menu -->
- <div class="step-ctx-menu" id="stepCtxMenu">
- <div class="step-ctx-item" onclick="stepCtxRerun()"><span class="sci-icon">🔄</span><span class="sci-label">Re-run from here</span></div>
- <div class="step-ctx-item" onclick="stepCtxViewInDAG()"><span class="sci-icon">🔍</span><span class="sci-label">Highlight in DAG</span></div>
- <div class="step-ctx-sep"></div>
- <div class="step-ctx-item" onclick="stepCtxCopyOutputs()"><span class="sci-icon">📋</span><span class="sci-label">Copy outputs</span></div>
- <div class="step-ctx-item" onclick="stepCtxCopyFiles()"><span class="sci-icon">📄</span><span class="sci-label">Copy file list</span></div>
- <div class="step-ctx-sep"></div>
- <div class="step-ctx-item" onclick="stepCtxToggleBody()"><span class="sci-icon">📂</span><span class="sci-label">Toggle details</span></div>
- <div class="step-ctx-item" onclick="stepCtxExpandAll()"><span class="sci-icon">⬇</span><span class="sci-label">Expand all sections</span></div>
- <div class="step-ctx-item" onclick="stepCtxCollapseAll()"><span class="sci-icon">⬆</span><span class="sci-label">Collapse all sections</span></div>
- </div>
- <!-- Settings Modal -->
- <div class="modal-overlay" id="settingsModal">
- <div class="modal-box">
- <h2>Settings</h2>
- <label>Claude LLM Provider</label>
- <div class="key-status" id="keyStatus" style="margin-bottom:8px;"></div>
- <div class="settings-provider-switch">
- <label class="settings-provider-option">
- <input type="radio" name="settingsProvider" id="settingsProviderCli" value="cli">
- <span class="settings-provider-copy">
- <strong>CLI</strong>
- <span>Default for daily use. Lower cost, works well when Claude CLI is installed.</span>
- </span>
- </label>
- <label class="settings-provider-option">
- <input type="radio" name="settingsProvider" id="settingsProviderApiKey" value="api-key">
- <span class="settings-provider-copy">
- <strong>API Key</strong>
- <span>Use Anthropic API directly when you want full cloud-only execution.</span>
- </span>
- </label>
- </div>
- <div class="settings-provider-hint" id="settingsProviderHint"></div>
- <label>API Key <span style="font-size:9px;color:var(--text2);">(optional if CLI subscription active)</span></label>
- <div class="key-row">
- <input type="password" id="settingsKey" placeholder="sk-ant-api03-...">
- <button class="hdr-btn" onclick="toggleKeyVisibility()">Show</button>
- </div>
- <label>Model</label>
- <select id="settingsModel">
- <option value="claude-opus-4-6">Claude Opus 4.6 (Most capable)</option>
- <option value="claude-sonnet-4-6">Claude Sonnet 4.6 (Faster)</option>
- <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 (Fastest)</option>
- </select>
- <label>Max Output Tokens</label>
- <input type="number" id="settingsMaxTokens" placeholder="32000" value="32000">
- <label>VL Platform <span style="font-size:9px;color:var(--text2)">(use Cloud button to login)</span></label>
- <div style="display:flex;align-items:center;gap:6px;">
- <span id="settingsCloudStatus" style="font-size:10px;color:var(--text2);">Not connected</span>
- <button class="hdr-btn" onclick="closeSettings();toggleCloudPanel();openCloudLogin();" style="font-size:9px;">Login</button>
- </div>
- <input type="hidden" id="settingsCookie">
- <label>Working Directory</label>
- <input type="text" id="settingsWorkDir" disabled style="opacity:0.6">
- <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
- <label>Official Doc IDs</label>
- <div class="settings-doc-hint">输入框里填的是 <code>Doc ID</code>。也支持直接粘贴 <code>vl://doc/<id></code> 或 <code>/doc-center.html?docId=<id></code>,保存时会自动归一成稳定 Doc ID;<code>Meta Spec / Workflow Spec</code> 仍保持只读。</div>
- <div class="settings-doc-grid" id="settingsDocIdCoreGrid"></div>
- <div class="settings-doc-hint">Workflow Docs</div>
- <div class="settings-doc-grid" id="settingsDocIdWorkflowGrid"></div>
- <div class="settings-doc-hint">Locked By Tooling</div>
- <div class="settings-doc-grid" id="settingsDocIdLockedGrid"></div>
- </div>
- <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
- <label>AutoTest</label>
- <div style="display:flex;flex-direction:column;gap:6px;">
- <label style="font-size:11px;display:flex;align-items:center;gap:6px;"><input type="checkbox" id="settingsHeadless"> Headless mode (hide browser window)</label>
- <label style="font-size:11px;display:flex;align-items:center;gap:6px;"><input type="checkbox" id="settingsUseWorkflow" checked> Use Workflow Engine (no API Key needed)</label>
- <div style="display:flex;gap:12px;">
- <label style="font-size:11px;display:flex;align-items:center;gap:4px;">Parallel browsers <input type="number" id="settingsParallelBrowsers" value="5" min="1" max="10" style="width:50px;"></label>
- <label style="font-size:11px;display:flex;align-items:center;gap:4px;">Max test cases <input type="number" id="settingsMaxCases" value="10" min="1" max="100" style="width:50px;"></label>
- </div>
- </div>
- </div>
- <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
- <label>Server</label>
- <div style="display:flex;gap:8px;align-items:center;">
- <span id="settingsVersion" style="font-size:11px;color:var(--text2);"></span>
- <button class="hdr-btn" onclick="location.reload();" style="font-size:10px;">Reload Page</button>
- </div>
- </div>
- <div class="modal-actions">
- <button class="hdr-btn" onclick="closeSettings()">Cancel</button>
- <button class="hdr-btn hdr-btn-primary" onclick="saveSettings()">Save</button>
- </div>
- </div>
- </div>
- <!-- Cloud Login Modal -->
- <div class="modal-overlay" id="cloudLoginModal">
- <div class="modal-box" style="max-width:400px;">
- <h2>VL Cloud Login</h2>
- <div id="cloudLoginError" style="display:none;color:var(--red);font-size:11px;padding:6px 8px;background:rgba(255,80,80,0.1);border-radius:4px;margin-bottom:8px;"></div>
- <!-- Login Tabs -->
- <div class="cloud-login-tabs">
- <div class="cl-tab active" data-tab="enterprise" onclick="switchLoginTab('enterprise')">Enterprise</div>
- <div class="cl-tab" data-tab="google" onclick="switchLoginTab('google')">Google</div>
- <div class="cl-tab" data-tab="token" onclick="switchLoginTab('token')">Token</div>
- </div>
- <!-- Enterprise Login -->
- <div class="cl-panel" id="clEnterprise">
- <label>Email</label>
- <input type="text" id="cloudUsername" placeholder="your@email.com" autocomplete="username">
- <label>Password</label>
- <input type="password" id="cloudPassword" placeholder="password" autocomplete="current-password">
- <label>Company Name</label>
- <input type="text" id="cloudCompany" placeholder="e.g. ivx">
- <div class="modal-actions">
- <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
- <button class="hdr-btn hdr-btn-primary" id="cloudLoginBtn" onclick="doEnterpriseLogin()">Login</button>
- </div>
- </div>
- <!-- Google Login -->
- <div class="cl-panel" id="clGoogle" style="display:none;">
- <div style="text-align:center;padding:12px 0;">
- <div id="googleSignInBtn" style="display:inline-block;"></div>
- <div id="googleSignInFallback" style="display:none;padding-top:12px;">
- <div style="font-size:11px;color:var(--text2);margin-bottom:8px;">Google Sign-In unavailable in this context.</div>
- <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Platform Login in Browser</button>
- <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
- After logging in with Google on the platform,<br>
- copy your <code>ih5bearer</code> cookie and paste it in the Token tab.
- </div>
- </div>
- </div>
- <div id="googleLoginStatus" style="text-align:center;font-size:11px;color:var(--text2);display:none;"></div>
- <div class="modal-actions">
- <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
- </div>
- </div>
- <!-- Token (Advanced) -->
- <div class="cl-panel" id="clToken" style="display:none;">
- <div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
- Paste your <code>ih5bearer</code> token from the VL platform cookie.<br>
- <span style="font-size:10px;">DevTools → Application → Cookies → ih5bearer</span>
- </div>
- <label>ih5bearer Token</label>
- <input type="text" id="cloudDirectCookie" placeholder="eyJhbGciOiJI..." style="font-size:10px;">
- <div class="modal-actions">
- <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
- <button class="hdr-btn hdr-btn-primary" onclick="doTokenLogin()">Connect</button>
- </div>
- </div>
- </div>
- </div>
- <!-- AutoTest Result Dialog -->
- <div class="modal-overlay" id="autotestResultModal">
- <div class="modal-box" style="max-width:500px;">
- <h2 style="color:var(--orange);">AutoTest Results</h2>
- <div id="autotestResultSummary" style="font-size:12px;margin-bottom:12px;"></div>
- <div id="autotestResultFailures" style="font-size:11px;max-height:200px;overflow-y:auto;margin-bottom:12px;background:var(--bg);padding:8px;border-radius:4px;"></div>
- <label style="font-size:11px;color:var(--text2);">Choose an action:</label>
- <div class="modal-actions" style="flex-direction:column;gap:6px;align-items:stretch;">
- <button class="hdr-btn hdr-btn-primary" onclick="autotestAction('fix')" style="text-align:left;padding:8px 12px;">
- AI Debug + Rerun — Send failures to AI for analysis and auto-fix
- </button>
- <button class="hdr-btn" onclick="autotestAction('report')" style="text-align:left;padding:8px 12px;">
- Generate Report — View detailed test report
- </button>
- <button class="hdr-btn" onclick="autotestAction('skip')" style="text-align:left;padding:8px 12px;">
- Skip — Close and handle manually
- </button>
- </div>
- </div>
- </div>
- <script>
- let currentFile = null;
- let openFiles = new Map(); // key → { type:'file'|'workflow'|'metadata', content?, title?, data? }
- let activeToolGroup = null;
- let ctxMenuTarget = null; // path of right-clicked file
- let currentMode = 'code'; // 'code' | 'meta' | 'flow' | 'docs' | 'preview'
- let _workflowActive = false; // true while a workflow is executing (prevents mode-stealing)
- let currentWorkDir = ''; // workspace folder path
- let currentPort = location.port ? parseInt(location.port) : 80; // this instance's port
- let _settingsSnapshot = null;
- let showInternalFiles = false;
- let workflowBindings = { generate: '3-file-codegen', adjust: 'incremental-update', autotest: 'autotest-pipeline' }; // workflow ID bindings
- let flowRunning = false; // true while a workflow is executing
- const $ = id => document.getElementById(id);
- // ===================== CODEMIRROR SETUP =====================
- // Define VL syntax mode for CodeMirror
- CodeMirror.defineMode('vl', function() {
- const keywords = new Set([
- 'APP','SECTION','COMPONENT','SERVICE','PUBLIC_SERVICE','HANDLER','METHOD','METHOD_PUB',
- 'TABLE','VT','VIRTUAL_TABLE','INDEX','RETURN','IF','ELSE','ELSEIF','FOR','IN','SET',
- 'CALL','GOTO','WHILE','BREAK','CONTINUE','ENUM','NAV','ROUTE','DEVICE_TARGET',
- 'SCREEN_RESOLUTION','STYLE','THEME','EVENT','DATA','FIELD','TRIGGER','BLOCK','TIMER',
- 'TEMPLATE','QUERY','PARAM','COMPUTED','EMIT','USE','DEFAULT','FROM','PUSH','REMOVE',
- 'MATCH','CONDITIONS','ALERT','CONFIRM','NAVIGATE','DISMISS','OPEN','CLOSE',
- 'sourceArray','loopVar','conditions','returns','params','value','options','label',
- 'placeholder','sourceTable','notNull','type','min-height','min-width',
- ]);
- const types = new Set([
- 'STRING','INT','FLOAT','BOOL','BOOLEAN','NUMBER','TIMESTAMP','OBJECT','ARRAY','NULL',
- 'UNIQUE','NORMAL','JSON','LIST','MAP','DATE','TEXT','DECIMAL',
- ]);
- const boolLit = new Set(['true','false','null','undefined']);
- // Style attributes that appear as key:value (e.g. padding:"12px", gap:"8px")
- const styleProps = new Set([
- 'padding','margin','gap','width','height','display','flex','color','background-color',
- 'background','border','border-radius','border-width','border-style','border-color',
- 'font-size','font-weight','font-family','text-transform','text-align','text-decoration',
- 'justify-content','align-items','flex-direction','flex-wrap','overflow','opacity',
- 'position','top','left','right','bottom','z-index','cursor','transition','box-shadow',
- 'margin-top','margin-bottom','margin-left','margin-right','padding-top','padding-bottom',
- 'padding-left','padding-right','border-bottom','border-top','border-left','border-right',
- 'max-width','max-height','min-width','min-height','line-height','letter-spacing',
- 'object-fit','white-space','word-break','text-overflow',
- ]);
- return {
- startState: function() { return { inTag: false }; },
- token: function(stream, state) {
- if (stream.eatSpace()) return null;
- // === VL tree dashes (indent markers) ===
- if (stream.sol() && stream.match(/^-+(?=\s|<|$)/)) return 'qualifier';
- // === Section headers: # Name, ## Frontend Tree, etc. ===
- if (stream.sol() && stream.match(/^#{1,3}\s+.*/)) return 'section-header';
- // === Comments ===
- if (stream.match(/^\/\/.*/)) return 'comment';
- // === Tags: <Component-X>, <Text-Title>, </Row>, <ServiceDomain-Bet> ===
- if (stream.match(/^<\/?[\w-]+/)) { state.inTag = true; return 'tag'; }
- if (state.inTag) {
- if (stream.eat('>')) { state.inTag = false; return 'tag'; }
- if (stream.match(/^"[^"]*"/)) return 'string';
- // key:value attributes inside tags (e.g. type:INT, sourceTable:Users)
- if (stream.match(/^[\w-]+(?=:)/)) return 'attribute';
- if (stream.eat(':')) return 'punctuation';
- // remaining words inside tag are attribute values
- if (stream.match(/^[\w.-]+/)) return 'variable';
- stream.next();
- return null;
- }
- // === $variables ===
- if (stream.match(/^\$\w+/)) return 'variable-2';
- // === @events / @handlers ===
- if (stream.match(/^@\w+/)) return 'def';
- // === Strings ===
- if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return 'string';
- if (stream.match(/^'(?:[^'\\]|\\.)*'/)) return 'string';
- // === Numbers ===
- if (stream.match(/^-?\d+(\.\d+)?(?!\w)/)) return 'number';
- // === CSS variables: --colorBrandPrimary ===
- if (stream.match(/^--[\w-]+/)) return 'atom';
- // === Hex colors: #1a1a1a ===
- if (stream.match(/^#[0-9a-fA-F]{3,8}\b/)) return 'number';
- // === Word tokens (keywords, types, identifiers, properties) ===
- if (stream.match(/^[\w-]+/)) {
- const w = stream.current();
- // Check keywords
- if (keywords.has(w)) return 'keyword';
- // Check types
- if (types.has(w)) return 'type';
- // Check booleans
- if (boolLit.has(w)) return 'atom';
- // Service.Method pattern: word followed by . and another word
- if (stream.peek() === '.' && stream.match(/^\.[\w]+(?=\s*\()/)) return 'def';
- // Property key before colon (e.g. padding: gap: font-size:)
- if (stream.peek() === ':') return 'property';
- // Style property names
- if (styleProps.has(w)) return 'property';
- // Known VL identifiers look-ahead: all-uppercase is likely a keyword/type we missed
- if (/^[A-Z][A-Z_]+$/.test(w)) return 'keyword';
- // PascalCase words are likely component/section/type references
- if (/^[A-Z][a-z]/.test(w)) return 'variable-3';
- // everything else — use default text color (light, visible)
- return 'variable';
- }
- // === Operators & punctuation ===
- if (stream.match(/^[(){}[\]]/)) return 'bracket';
- if (stream.match(/^[=!<>]+/)) return 'operator';
- if (stream.match(/^[,:;|&+*/%-]/)) return 'punctuation';
- // Fallback: advance one char, return visible style
- stream.next();
- return 'variable';
- }
- };
- });
- // Register file extension → CodeMirror mode mapping
- const CM_MODE_MAP = {
- 'vx': 'vl', 'sc': 'vl', 'cp': 'vl', 'vs': 'vl', 'vdb': 'vl', 'vth': 'vl',
- 'js': 'javascript', 'mjs': 'javascript', 'cjs': 'javascript',
- 'json': { name: 'javascript', json: true },
- 'css': 'css',
- 'html': 'htmlmixed', 'htm': 'htmlmixed',
- 'xml': 'xml', 'svg': 'xml',
- 'md': 'text/plain', 'txt': 'text/plain',
- };
- function getCmMode(filePath) {
- const ext = (filePath || '').split('.').pop().toLowerCase();
- return CM_MODE_MAP[ext] || 'text/plain';
- }
- // Global CodeMirror editor instance
- let cmEditor = null;
- function initCodeMirror() {
- if (cmEditor) return;
- if (typeof CodeMirror === 'undefined') {
- console.warn('CodeMirror not loaded, using textarea fallback');
- cmEditor = null;
- return;
- }
- cmEditor = CodeMirror($('cmEditorWrap'), {
- value: '',
- mode: 'vl',
- theme: 'default',
- lineNumbers: true,
- matchBrackets: true,
- autoCloseBrackets: true,
- styleActiveLine: true,
- indentUnit: 2,
- tabSize: 2,
- indentWithTabs: false,
- lineWrapping: false,
- foldGutter: true,
- gutters: ['CodeMirror-linenumber', 'CodeMirror-foldgutter'],
- extraKeys: {
- 'Cmd-S': function() { saveCurrentFile(); },
- 'Ctrl-S': function() { saveCurrentFile(); },
- 'Tab': function(cm) {
- if (cm.somethingSelected()) cm.indentSelection('add');
- else cm.replaceSelection(' ', 'end');
- },
- }
- });
- // Track changes: update openFiles map
- cmEditor.on('change', function() {
- if (currentFile && openFiles.has(currentFile)) {
- const info = openFiles.get(currentFile);
- if (info.type === 'file') info.content = cmEditor.getValue();
- }
- });
- }
- // Chat state
- let pendingImages = []; // [{data: base64, mediaType, preview}]
- let pendingMentions = []; // [filename]
- let allFileNames = []; // for @-mention autocomplete
- let mentionIdx = -1; // selected autocomplete index
- // Multi-conversation state — persisted in localStorage
- let conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
- let activeConvId = 0;
- let convIdCounter = 1;
- function resetConversationState() {
- conversations = [{ id: 0, name: 'Chat 1', messages: [], dom: '' }];
- activeConvId = 0;
- convIdCounter = 1;
- if ($('chatMessages')) $('chatMessages').innerHTML = '';
- renderConvTabs();
- }
- function setWorkspaceTriggerHighlight(active) {
- const btn = document.querySelector('.ws-current');
- if (btn) btn.classList.toggle('ws-btn-highlight', !!active);
- }
- function chatStorageKey(wsPath) {
- const p = wsPath || currentWorkDir || '_global';
- return 'vl-code-chat:' + p;
- }
- function saveChatState(wsPath) {
- try {
- const cur = conversations.find(c => c.id === activeConvId);
- if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
- const state = { conversations, activeConvId, convIdCounter };
- localStorage.setItem(chatStorageKey(wsPath), JSON.stringify(state));
- } catch {}
- }
- /** Fetch chat state from backend (single source of truth) */
- async function fetchChatStateFromServer() {
- try {
- const res = await fetch('/api/chat/state');
- if (!res.ok) return false;
- const data = await res.json();
- if (!data?.conversations?.length) return false;
- conversations = data.conversations.map(c => ({
- id: c.id, name: c.name, messages: c.messages || [], dom: c.dom || '',
- messageCount: c.messageCount || 0,
- }));
- activeConvId = data.activeConvId ?? 0;
- convIdCounter = data.convIdCounter ?? conversations.length;
- // Auto-switch: if active conversation is empty but others have messages, pick first with messages
- const cur = conversations.find(c => c.id === activeConvId);
- if ((!cur || cur.messageCount === 0) && !cur?.dom) {
- const withMessages = conversations.find(c => c.messageCount > 0 || c.dom);
- if (withMessages) {
- activeConvId = withMessages.id;
- }
- }
- const target = conversations.find(c => c.id === activeConvId);
- if ($('chatMessages')) {
- if (target?.dom) {
- // Check if DOM is stale (fewer messages than server has)
- const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
- if (domMsgCount < (target.messageCount || 0)) {
- // DOM is stale — rebuild from server messages
- $('chatMessages').innerHTML = '';
- await _rebuildChatDom(target.id);
- } else {
- $('chatMessages').innerHTML = target.dom;
- }
- } else {
- $('chatMessages').innerHTML = '';
- // If dom is empty but server has messages, rebuild from server
- if (target?.messageCount > 0) {
- await _rebuildChatDom(target.id);
- }
- }
- }
- renderConvTabs();
- // Write-through to localStorage as offline fallback
- saveChatState();
- return true;
- } catch { return false; }
- }
- /**
- * Rebuild chat DOM from server-side messages when dom snapshot is empty.
- * Server endpoint already strips system-reminder content and flattens content blocks.
- */
- async function _rebuildChatDom(convId) {
- try {
- const res = await fetch(`/api/conversations/${convId}/messages`);
- if (!res.ok) return;
- const data = await res.json();
- const msgs = data.messages || [];
- if (!msgs.length) return;
- const container = $('chatMessages');
- if (!container) return;
- container.innerHTML = '';
- for (const m of msgs) {
- if (!m.role || !m.content) continue;
- const el = addMsg(m.role, m.content);
- if (m.role === 'assistant' && el) finalizeAssistantMsg(el);
- }
- // Save rebuilt DOM into conversation object so it doesn't need rebuilding again
- const conv = conversations.find(c => c.id === convId);
- if (conv) conv.dom = container.innerHTML;
- // Push rebuilt DOM to server for persistence
- pushChatStateToServer();
- } catch (e) {
- console.warn('[RebuildDom] Failed for conv', convId, e);
- }
- }
- /** Push chat state to backend (periodic save) */
- async function pushChatStateToServer() {
- const cur = conversations.find(c => c.id === activeConvId);
- if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
- const state = {
- conversations: conversations.map(c => ({
- id: c.id, name: c.name,
- dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
- })),
- activeConvId,
- convIdCounter,
- };
- try {
- await fetch('/api/chat/state', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(state),
- });
- } catch {}
- // Write-through to localStorage
- saveChatState();
- }
- /** Persist debug log entries to filesystem */
- async function persistDebugLog() {} // Debug panel removed
- function loadChatState(wsPath) {
- try {
- const key = chatStorageKey(wsPath);
- const raw = localStorage.getItem(key);
- // Also try migrating old global key on first load
- const fallback = !wsPath && !raw ? localStorage.getItem('vl-code-chat') : null;
- const data = raw || fallback;
- if (!data) return;
- const state = JSON.parse(data);
- if (state.conversations?.length) {
- conversations = state.conversations;
- activeConvId = state.activeConvId || 0;
- convIdCounter = state.convIdCounter || conversations.length;
- const cur = conversations.find(c => c.id === activeConvId);
- if (cur?.dom && $('chatMessages')) {
- $('chatMessages').innerHTML = cur.dom;
- }
- renderConvTabs();
- }
- // Clean up old global key after migration
- if (fallback) localStorage.removeItem('vl-code-chat');
- } catch {}
- }
- /** Sync client conversations with server sessions so chatId targeting works after refresh */
- async function syncSessionsFromServer() {
- try {
- const data = await api('/api/sessions');
- if (!data?.sessions?.length) return;
- let changed = false;
- for (const s of data.sessions) {
- const id = Number(s.chatId);
- if (isNaN(id)) continue;
- if (!conversations.some(c => c.id === id)) {
- conversations.push({ id, name: s.summary ? s.summary.substring(0, 30) : `Chat ${id + 1}`, messages: [] });
- changed = true;
- }
- if (id >= convIdCounter) convIdCounter = id + 1;
- }
- if (changed) {
- renderConvTabs();
- saveChatState();
- }
- } catch {}
- }
- async function clearChatHistory() {
- // Backend first — clears sessions + registry + broadcasts to other tabs
- try {
- await fetch('/api/conversations', { method: 'DELETE' });
- } catch {}
- // Then local
- localStorage.removeItem(chatStorageKey());
- conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
- activeConvId = 0;
- convIdCounter = 1;
- $('chatMessages').innerHTML = '';
- renderConvTabs();
- saveChatState();
- }
- // ===================== CHAT PANEL: RESIZE + COLLAPSE =====================
- let chatWidth = parseInt(localStorage.getItem('vl-chat-width') || '400');
- function getDockedChatReserveWidth() {
- const panel = $('chatPanel');
- if (!panel || panel.classList.contains('floating')) return 0;
- return panel.classList.contains('collapsed') ? 36 : chatWidth;
- }
- function syncDockedLayout() {
- const reserve = getDockedChatReserveWidth();
- const panels = document.querySelector('.panels');
- const detailPanel = $('detailPanel');
- if (panels) panels.style.marginRight = reserve + 'px';
- if (detailPanel) detailPanel.style.right = reserve > 0 ? `${reserve + 1}px` : '0';
- }
- function applyChatWidth(w) {
- chatWidth = Math.max(300, Math.min(800, w));
- const panel = $('chatPanel');
- if (!panel.classList.contains('collapsed')) panel.style.width = chatWidth + 'px';
- syncDockedLayout();
- localStorage.setItem('vl-chat-width', String(chatWidth));
- }
- (function initChatResize() {
- document.addEventListener('DOMContentLoaded', () => {
- applyChatWidth(chatWidth);
- const handle = $('chatResizeHandle');
- if (!handle) return;
- handle.addEventListener('mousedown', function(e) {
- e.preventDefault();
- this.classList.add('dragging');
- const startX = e.clientX, startW = chatWidth;
- function onMove(ev) { applyChatWidth(startW + (startX - ev.clientX)); }
- function onUp() {
- document.removeEventListener('mousemove', onMove);
- document.removeEventListener('mouseup', onUp);
- handle.classList.remove('dragging');
- if (typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
- }
- document.addEventListener('mousemove', onMove);
- document.addEventListener('mouseup', onUp);
- });
- // Drag-to-move chat panel by header
- const header = document.querySelector('.chat-header');
- const panel = $('chatPanel');
- if (header && panel) {
- header.addEventListener('mousedown', function(e) {
- // Don't drag if clicking buttons inside header
- if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
- if (panel.classList.contains('collapsed')) return;
- e.preventDefault();
- const rect = panel.getBoundingClientRect();
- const offsetX = e.clientX - rect.left;
- const offsetY = e.clientY - rect.top;
- const wasDocked = !panel.classList.contains('floating');
- function onMove(ev) {
- if (wasDocked && Math.abs(ev.clientX - e.clientX) < 8 && Math.abs(ev.clientY - e.clientY) < 8) return;
- if (!panel.classList.contains('floating')) {
- panel.classList.add('floating');
- panel.style.height = Math.min(rect.height, window.innerHeight - 40) + 'px';
- panel.style.right = 'auto';
- panel.style.top = 'auto';
- panel.style.bottom = 'auto';
- syncDockedLayout();
- }
- let nx = ev.clientX - offsetX;
- let ny = ev.clientY - offsetY;
- nx = Math.max(0, Math.min(window.innerWidth - 100, nx));
- ny = Math.max(0, Math.min(window.innerHeight - 40, ny));
- panel.style.left = nx + 'px';
- panel.style.top = ny + 'px';
- }
- function onUp(ev) {
- document.removeEventListener('mousemove', onMove);
- document.removeEventListener('mouseup', onUp);
- // Snap back to docked if dragged to right edge
- if (panel.classList.contains('floating')) {
- const pr = panel.getBoundingClientRect();
- if (pr.right >= window.innerWidth - 20) {
- snapChatDocked();
- }
- }
- }
- document.addEventListener('mousemove', onMove);
- document.addEventListener('mouseup', onUp);
- });
- }
- });
- })();
- /** Snap chat panel back to docked right-side position */
- function snapChatDocked() {
- const panel = $('chatPanel');
- panel.classList.remove('floating');
- panel.style.left = '';
- panel.style.top = '32px';
- panel.style.right = '0';
- panel.style.height = 'calc(100vh - 32px)';
- panel.style.bottom = '';
- applyChatWidth(chatWidth);
- }
- function toggleChatCollapse() {
- const panel = $('chatPanel');
- const isCollapsed = panel.classList.toggle('collapsed');
- const w = isCollapsed ? 36 : chatWidth;
- panel.style.width = w + 'px';
- syncDockedLayout();
- // Update collapse button arrow direction
- const btn = panel.querySelector('.chat-collapse-btn');
- if (btn) btn.innerHTML = isCollapsed ? '▶' : '◀';
- if (!isCollapsed && typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
- // Detail Panel follows main chat: hide when collapsed, restore when expanded
- const detailPanel = $('detailPanel');
- if (isCollapsed) {
- // Save detail panel state before hiding
- detailPanel._wasOpenBeforeCollapse = detailPanel.classList.contains('open');
- detailPanel.classList.remove('open');
- } else {
- // Restore detail panel if it was open before collapse
- if (detailPanel._wasOpenBeforeCollapse && !_detailManualClosed) {
- detailPanel.classList.add('open');
- }
- }
- }
- function sendSkillCmd(name) {
- $('chatInput').value = '/' + name;
- sendMessage();
- }
- // ===================== ACTION SHORTCUTS =====================
- function executeAction(actionName) {
- const skillActions = {
- 'validate': 'validate-all',
- 'blueprint': 'blueprint',
- 'deploy': 'deploy',
- 'debug': 'debug',
- };
- const skillName = skillActions[actionName];
- if (skillName) {
- sendSkillCmd(skillName);
- return;
- }
- setStatus('Unknown action: ' + actionName, 'red');
- }
- // ===================== WORKFLOW PROGRESS IN CHAT =====================
- let _activeWfProgress = null;
- /** Create a workflow progress widget in the chat panel */
- function addWorkflowProgress(workflowName, steps) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'wf-progress';
- div.id = 'wfProgress_' + Date.now();
- const stepsHtml = steps.map(s => {
- const typeLabel = s.type || s.id.split('_')[0];
- return '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
- '<span class="wf-step-dot pending"></span>' +
- '<span>' + escapeHtml(s.title || s.id) + '</span>' +
- '<span class="wf-step-type">' + escapeHtml(typeLabel) + '</span>' +
- '</div>';
- }).join('');
- div.innerHTML = '<div class="wf-progress-header">' +
- '<span class="wf-icon">⚙</span>' +
- '<span>' + escapeHtml(workflowName) + '</span>' +
- '</div>' + stepsHtml;
- container.appendChild(div);
- _activeWfProgress = div;
- scrollChat();
- return div;
- }
- /** Update a node's status dot in the active workflow progress widget */
- function updateWfProgressNode(nodeId, status) {
- if (!_activeWfProgress) return;
- const step = _activeWfProgress.querySelector('[data-node-id="' + nodeId + '"]');
- if (!step) return;
- const dot = step.querySelector('.wf-step-dot');
- if (dot) dot.className = 'wf-step-dot ' + status;
- step.classList.toggle('active', status === 'running');
- step.classList.toggle('completed', status === 'done');
- }
- /** Show a workflow approval prompt in chat (for LLM-generated workflows) */
- function addWorkflowApproval(wfData) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'wf-progress';
- const stepsHtml = (wfData.steps || []).map(s =>
- '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
- '<span class="wf-step-dot pending"></span>' +
- '<span>' + escapeHtml(s.title || s.id) + '</span>' +
- '<span class="wf-step-type">' + escapeHtml(s.type || '') + '</span>' +
- '</div>'
- ).join('');
- div.innerHTML = '<div class="wf-progress-header">' +
- '<span class="wf-icon">🛠</span>' +
- '<span>Workflow: ' + escapeHtml(wfData.workflow?.name || wfData.name || 'Untitled') + '</span>' +
- '</div>' +
- stepsHtml +
- '<div class="wf-progress-actions" id="wfApprovalActions_' + (wfData.name || '') + '">' +
- '<button class="wf-approve-btn" onclick="approveAndRunWorkflow(\'' + escapeHtml(wfData.name || '') + '\', this)">✓ Approve & Run</button>' +
- '<button class="wf-cancel-btn" onclick="this.closest(\'.wf-progress-actions\').innerHTML=\'<span style=color:var(--red);font-size:10px>Cancelled.</span>\'">✗ Cancel</button>' +
- '<button onclick="viewWorkflow(\'' + escapeHtml(wfData.name || '') + '\');switchMode(\'flow\')">View DAG</button>' +
- '</div>';
- container.appendChild(div);
- scrollChat();
- return div;
- }
- // ── Workflow LLM Chat Streaming ──
- // Streams LLM thinking/response/tool messages into the main chat window
- // during local workflow execution, so the user sees everything in real-time.
- let _wfLlmChatEl = null; // Current chat message element
- let _wfLlmThinkingEl = null; // Thinking collapsible block inside chat
- let _wfLlmResponseEl = null; // Response text element inside chat
- let _wfLlmRawText = ''; // Raw accumulated response for markdown render
- let _wfLlmFlushTimer = null; // Batch DOM updates
- function _wfLlmChatEnsure() {
- if (_wfLlmChatEl) return;
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'msg assistant';
- div.style.position = 'relative';
- const now = formatMsgTime(new Date());
- div.innerHTML = `<div class="label">assistant <span class="msg-time">${now}</span></div>` +
- `<div class="wf-llm-thinking" style="display:none;margin:4px 0;padding:6px 8px;background:var(--bg2);border-radius:4px;border-left:2px solid var(--purple);font-size:10px;color:var(--text2);max-height:150px;overflow:auto;cursor:pointer;font-style:italic;" onclick="this.style.maxHeight=this.style.maxHeight==='150px'?'none':'150px'"></div>` +
- `<span class="content-text"></span>`;
- container.appendChild(div);
- _wfLlmChatEl = div;
- _wfLlmThinkingEl = div.querySelector('.wf-llm-thinking');
- _wfLlmResponseEl = div.querySelector('.content-text');
- _wfLlmRawText = '';
- scrollChat();
- }
- function _wfLlmChatAppend(type, text) {
- _wfLlmChatEnsure();
- if (type === 'thinking') {
- _wfLlmThinkingEl.style.display = '';
- _wfLlmThinkingEl.textContent += text;
- } else {
- _wfLlmRawText += text;
- // Batch DOM updates for smooth rendering
- if (!_wfLlmFlushTimer) {
- _wfLlmFlushTimer = setTimeout(() => {
- _wfLlmResponseEl.textContent = _wfLlmRawText;
- _wfLlmFlushTimer = null;
- scrollChat();
- }, 100);
- }
- }
- }
- function _wfLlmChatToolUse(name, input) {
- _wfLlmChatEnsure();
- const toolDiv = document.createElement('div');
- toolDiv.style.cssText = 'margin:4px 0;padding:4px 8px;background:var(--bg2);border-radius:4px;font-size:10px;border-left:2px solid var(--accent);';
- const inputStr = JSON.stringify(input);
- const short = inputStr.length > 120 ? inputStr.slice(0, 120) + '...' : inputStr;
- toolDiv.innerHTML = `<span style="color:var(--accent);font-weight:600;">🔧 ${escapeHtml(name)}</span> <span style="color:var(--text2);">${escapeHtml(short)}</span>`;
- toolDiv.style.cursor = 'pointer';
- toolDiv.title = 'Click to expand';
- toolDiv.onclick = () => { toolDiv.querySelector('.wf-tool-full')?.classList.toggle('collapsed'); };
- if (inputStr.length > 120) {
- toolDiv.innerHTML += `<div class="wf-tool-full collapsed" style="margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2);">${escapeHtml(inputStr)}</div>`;
- }
- _wfLlmChatEl.appendChild(toolDiv);
- scrollChat();
- }
- function _wfLlmChatToolResult(result, isError) {
- _wfLlmChatEnsure();
- const resDiv = document.createElement('div');
- resDiv.style.cssText = `margin:2px 0 4px;padding:4px 8px;background:var(--bg2);border-radius:4px;font-size:10px;border-left:2px solid ${isError ? 'var(--red)' : 'var(--green)'};max-height:100px;overflow:auto;cursor:pointer;`;
- resDiv.onclick = () => { resDiv.style.maxHeight = resDiv.style.maxHeight === '100px' ? 'none' : '100px'; };
- const short = result.length > 200 ? result.slice(0, 200) + '...' : result;
- resDiv.innerHTML = `<span style="color:${isError ? 'var(--red)' : 'var(--green)'};font-weight:600;">${isError ? '✗' : '✓'} Result</span> <span style="color:var(--text2);white-space:pre-wrap;">${escapeHtml(short)}</span>`;
- if (result.length > 200) {
- resDiv.innerHTML += `<div style="margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2);">${escapeHtml(result)}</div>`;
- }
- _wfLlmChatEl.appendChild(resDiv);
- scrollChat();
- }
- function _wfLlmChatFinalize(summary) {
- if (!_wfLlmChatEl) return;
- // Flush pending text
- if (_wfLlmFlushTimer) { clearTimeout(_wfLlmFlushTimer); _wfLlmFlushTimer = null; }
- // Render markdown
- if (_wfLlmRawText && _wfLlmResponseEl) {
- _wfLlmResponseEl.innerHTML = renderMarkdown(_wfLlmRawText);
- // Add Apply buttons to code blocks
- _wfLlmResponseEl.querySelectorAll('pre').forEach(pre => {
- const btn = document.createElement('button');
- btn.className = 'code-apply';
- btn.textContent = 'Apply';
- btn.onclick = () => applyCodeBlock(pre);
- pre.style.position = 'relative';
- pre.appendChild(btn);
- });
- }
- // Add usage summary footer
- if (summary) {
- const footer = document.createElement('div');
- footer.style.cssText = 'font-size:9px;color:var(--text2);margin-top:4px;padding-top:4px;border-top:1px solid var(--border);';
- footer.textContent = summary;
- _wfLlmChatEl.appendChild(footer);
- }
- scrollChat();
- // Reset for next LLM call within same workflow
- _wfLlmChatEl = null;
- _wfLlmThinkingEl = null;
- _wfLlmResponseEl = null;
- _wfLlmRawText = '';
- }
- function _wfLlmChatError(errMsg, retryable) {
- _wfLlmChatEnsure();
- const errDiv = document.createElement('div');
- errDiv.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;';
- errDiv.textContent = `✗ LLM Error${retryable ? ' (retryable)' : ''}: ${errMsg}`;
- _wfLlmChatEl.appendChild(errDiv);
- scrollChat();
- _wfLlmChatEl = null;
- _wfLlmThinkingEl = null;
- _wfLlmResponseEl = null;
- _wfLlmRawText = '';
- }
- /** Approve and execute a workflow from chat */
- async function approveAndRunWorkflow(name, btn) {
- const actionsDiv = btn.closest('.wf-progress-actions');
- actionsDiv.innerHTML = '<span style="color:var(--green);font-size:10px;">Approved. Executing...</span>';
- const approvalWidget = btn.closest('.wf-progress');
- _activeWfProgress = approvalWidget;
- try {
- const res = await fetch('/api/workflow/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflowName: name, params: {} }),
- });
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const blocks = buffer.split('\n\n');
- buffer = blocks.pop();
- for (const block of blocks) {
- let eType = 'message', eData = null;
- for (const line of block.split('\n')) {
- if (line.startsWith('event: ')) eType = line.slice(7).trim();
- else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
- }
- if (!eData) continue;
- switch (eType) {
- case 'workflow_start': {
- const wfModel = eData.model ? ` [${eData.model}]` : '';
- addDetailEntry('workflow', `► Workflow started: ${eData.name || ''}${wfModel} (${eData.stepCount || '?'} steps)`, null, 'info');
- addMsg('assistant', `**▶ Workflow: ${eData.name || 'running'}**${wfModel ? ' — Model: ' + eData.model : ''}`);
- break;
- }
- case 'node_start': {
- updateWfProgressNode(eData.nodeId, 'running');
- const nsLabel = eData.title || eData.nodeId;
- const nsType = eData.type ? `[${eData.type}] ` : '';
- const nsInput = eData.input ? JSON.stringify(eData.input, null, 2) : null;
- addDetailEntry('node', `▶ ${nsType}${nsLabel}`, nsInput, 'info');
- addMsg('assistant', `**Step: ${nsType}${nsLabel}**`);
- break;
- }
- case 'node_done': {
- updateWfProgressNode(eData.nodeId, 'done');
- const ndLabel = eData.title || eData.nodeId;
- const ndDur = eData.duration_ms ? ` (${eData.duration_ms >= 1000 ? (eData.duration_ms / 1000).toFixed(1) + 's' : eData.duration_ms + 'ms'})` : '';
- const ndOutput = eData.output ? JSON.stringify(eData.output, null, 2) : null;
- addDetailEntry('node', `✓ ${ndLabel}${ndDur}`, ndOutput, 'success');
- break;
- }
- case 'node_error': {
- updateWfProgressNode(eData.nodeId, 'error');
- const neLabel = eData.title || eData.nodeId;
- const neType = eData.type ? `[${eData.type}] ` : '';
- const neDur = eData.duration_ms ? ` (${(eData.duration_ms / 1000).toFixed(1)}s)` : '';
- addDetailEntry('node', `✗ ${neType}${neLabel}${neDur} — ${eData.error || ''}`, eData.detail || null, 'error');
- addMsg('assistant', `**✗ Error: ${neLabel}** — ${eData.error || ''}`);
- break;
- }
- case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
- // ── Extended LLM events → Detail Log + Main Chat ──
- case 'llm_thinking':
- appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
- _wfLlmChatAppend('thinking', eData.delta || '');
- break;
- case 'token':
- appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || eData.delta || '');
- _wfLlmChatAppend('response', eData.token || eData.delta || '');
- break;
- case 'llm_tool_use': {
- const ltuInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
- addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, ltuInput, 'info', { depth: 1 });
- _wfLlmChatToolUse(eData.name || 'unknown', eData.input || {});
- break;
- }
- case 'llm_tool_result': {
- const isErr = eData.is_error || false;
- const rc = eData.content || '';
- const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
- addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${eData.name || 'Result'}${eData.tool_use_id ? ' [' + eData.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
- _wfLlmChatToolResult(rs, isErr);
- break;
- }
- case 'tool_start': {
- const toolInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
- addDetailEntry('tool-call', `🛠 ${eData.name || eData.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
- break;
- }
- case 'tool_done': {
- const toolOutput = eData.output ? (typeof eData.output === 'string' ? eData.output : JSON.stringify(eData.output, null, 2)) : null;
- addDetailEntry('tool-result', `✓ ${eData.name || eData.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
- break;
- }
- case 'tool_error': {
- addDetailEntry('tool-result', `✗ ${eData.name || eData.stepId || 'tool'}${eData.allowError ? ' (continued)' : ''}`, eData.error || null, eData.allowError ? 'warn' : 'error', { depth: 1 });
- break;
- }
- case 'tool_message': {
- const detail = eData.data ? (typeof eData.data === 'string' ? eData.data : JSON.stringify(eData.data, null, 2)) : null;
- addDetailEntry('tool-call', `• ${eData.name || eData.stepId || 'tool'}: ${eData.message || ''}`, detail, eData.level === 'error' ? 'error' : eData.level === 'warn' ? 'warn' : 'info', { depth: 1 });
- break;
- }
- case 'llm_done': {
- flushStreamBoxes();
- const mdl = eData.model || '';
- const usg = eData.usage || {};
- const inTok = usg.input_tokens || 0;
- const outTok = usg.output_tokens || 0;
- const cacheTok = usg.cache_read_input_tokens || 0;
- const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
- const tokParts = [];
- if (inTok) tokParts.push(`in:${inTok}`);
- if (cacheTok) tokParts.push(`cache:${cacheTok}`);
- if (outTok) tokParts.push(`out:${outTok}`);
- const parts = [mdl, tokParts.join(' '), lat].filter(Boolean).join(' | ');
- addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
- _wfLlmChatFinalize(parts);
- break;
- }
- case 'llm_error': {
- const errParts = [eData.error || 'Unknown'];
- if (eData.type) errParts.push(`type:${eData.type}`);
- if (eData.code) errParts.push(`code:${eData.code}`);
- addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${errParts.join(' | ')}`, eData, 'error');
- _wfLlmChatError(eData.error || 'Unknown LLM error', eData.retryable);
- break;
- }
- case 'var_changed': {
- const vn = eData.name || '?';
- const vo = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 120) : '—';
- const vn2 = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 120) : '—';
- addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, eData, 'info', { depth: 1 });
- break;
- }
- case 'file_start':
- addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
- break;
- case 'pause':
- updateWfProgressNode(eData.nodeId, 'paused');
- addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
- addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
- break;
- case 'resumed':
- updateWfProgressNode(eData.nodeId, 'running');
- addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
- break;
- case 'file_written':
- { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
- break;
- case 'done':
- flushStreamBoxes();
- addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
- addDetailEntry('workflow', 'Workflow completed', null, 'success');
- await loadFileTree();
- break;
- case 'error':
- addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
- addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
- break;
- }
- }
- }
- } catch (e) {
- addMsg('assistant', '**Workflow execution error:** ' + e.message);
- }
- _activeWfProgress = null;
- }
- /** Show Pause/Resume UI in chat */
- function addPauseResumeUI(nodeId, title, runID) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'wf-progress';
- div.innerHTML = '<div class="wf-progress-header">' +
- '<span class="wf-icon" style="color:var(--purple);">⏸</span>' +
- '<span>Paused: ' + escapeHtml(title || nodeId) + '</span>' +
- '</div>' +
- '<div style="padding:4px 0;font-size:10px;color:var(--text2);">Review the current state and approve to continue.</div>' +
- '<div class="wf-progress-actions">' +
- '<button class="wf-approve-btn">✓ Continue</button>' +
- '<button class="wf-cancel-btn">✗ Abort</button>' +
- '</div>';
- const approveBtn = div.querySelector('.wf-approve-btn');
- const cancelBtn = div.querySelector('.wf-cancel-btn');
- if (approveBtn) approveBtn.onclick = () => resumeWorkflow(nodeId, runID, approveBtn);
- if (cancelBtn) cancelBtn.onclick = () => cancelWorkflow(nodeId, runID, cancelBtn);
- container.appendChild(div);
- scrollChat();
- }
- /** Resume a paused workflow */
- async function resumeWorkflow(nodeId, runID, btn) {
- btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--green);font-size:10px;">Resumed...</span>';
- await fetch('/api/workflow/resume', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ runID, nodeId, payload: { approved: true } }),
- });
- }
- /** Cancel a paused workflow */
- async function cancelWorkflow(nodeId, runID, btn) {
- btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--red);font-size:10px;">Aborted.</span>';
- await fetch('/api/workflow/cancel', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ runID, nodeId }),
- });
- }
- // ===================== LANDING PAGE =====================
- function switchLandingTab(tab) {
- document.querySelectorAll('.landing-tab').forEach(t => t.classList.toggle('active', t.dataset.ltab === tab));
- document.querySelectorAll('.landing-tab-panel').forEach(p => p.classList.remove('active'));
- const panelMap = { enterprise: 'ltEnterprise', google: 'ltGoogle', token: 'ltToken' };
- if (panelMap[tab]) $(panelMap[tab]).classList.add('active');
- }
- async function doLandingEnterpriseLogin() {
- const username = $('landingUsername').value.trim();
- const password = $('landingPassword').value.trim();
- const companyName = $('landingCompany').value.trim();
- if (!username || !password) { $('landingLoginError').textContent = 'Email and password required'; $('landingLoginError').style.display = 'block'; return; }
- $('landingLoginError').style.display = 'none';
- try {
- const res = await fetch('/api/cloud/login', {
- method: 'POST', headers: {'Content-Type':'application/json'},
- body: JSON.stringify({ username, password, companyName })
- });
- const data = await res.json();
- if (data.error) { $('landingLoginError').textContent = data.error; $('landingLoginError').style.display = 'block'; return; }
- _cloudConnected = true;
- enterIDE();
- } catch (e) { $('landingLoginError').textContent = e.message; $('landingLoginError').style.display = 'block'; }
- }
- async function doLandingTokenLogin() {
- const cookie = $('landingDirectCookie').value.trim();
- if (!cookie) return;
- await fetch('/api/cookie/refresh', {
- method: 'POST', headers: {'Content-Type':'application/json'},
- body: JSON.stringify({ cookie })
- });
- _cloudConnected = true;
- enterIDE();
- }
- function refreshLandingDocsFrame() {
- const frame = $('landingDocsFrame');
- if (!frame) return;
- frame.src = buildDocCenterEmbedSrc({ embed: 'landing', force: true });
- }
- async function enterIDE() {
- // Save API key if provided
- const apiKey = $('landingApiKey')?.value?.trim();
- if (apiKey) {
- await fetch('/api/settings', {
- method: 'POST', headers: {'Content-Type':'application/json'},
- body: JSON.stringify({ apiKey })
- });
- }
- // Mark as entered and hide landing
- sessionStorage.setItem('vlcode_entered', '1');
- $('landingOverlay').classList.remove('active');
- await initIDE();
- }
- async function checkCliStatus() {
- try {
- const res = await fetch('/api/cli-status');
- const data = await res.json();
- const el = $('landingCliStatus');
- if (data.available) {
- el.innerHTML = '<span style="color:var(--green);">✓ Claude CLI detected — Team subscription active, no API Key needed</span>';
- el.style.display = 'block';
- } else {
- el.innerHTML = '<span style="color:var(--yellow);">⚠ Claude CLI not detected — API Key may be needed</span>';
- el.style.display = 'block';
- }
- return data;
- } catch { return { available: false }; }
- }
- function updateLlmBadge(provider) {
- const badge = $('llmBadge');
- if (!badge) return;
- if (provider === 'cli') {
- badge.textContent = 'CLI';
- badge.className = 'llm-badge cli';
- badge.title = 'Using Claude CLI (Team subscription)';
- } else if (provider === 'api-key') {
- badge.textContent = 'API Key';
- badge.className = 'llm-badge apikey';
- badge.title = 'Using Anthropic API Key';
- } else {
- badge.textContent = provider || 'CLI';
- badge.className = 'llm-badge cli';
- }
- }
- function getSelectedSettingsProvider() {
- return document.querySelector('input[name="settingsProvider"]:checked')?.value || 'cli';
- }
- function renderProviderSettingsState(settings = {}) {
- _settingsSnapshot = { ...(_settingsSnapshot || {}), ...settings };
- const selected = _settingsSnapshot.llmProvider || 'cli';
- const effective = _settingsSnapshot.effectiveProvider || selected;
- const cliAvailable = !!_settingsSnapshot.cliAvailable;
- const hasApiKey = !!_settingsSnapshot.hasApiKey;
- $('settingsProviderCli').checked = selected === 'cli';
- $('settingsProviderApiKey').checked = selected === 'api-key';
- const summary = effective === 'cli'
- ? '<span class="key-ok" style="color:var(--green);">Active provider: CLI</span>'
- : '<span class="key-ok">Active provider: API Key</span>';
- $('keyStatus').innerHTML = summary;
- let hint = '';
- if (selected === 'cli') {
- hint = cliAvailable
- ? 'CLI mode selected. This is the recommended lower-cost path when Claude CLI is installed.'
- : (hasApiKey
- ? 'CLI mode selected, but Claude CLI is not available here. Runtime will fall back to API Key until CLI is installed.'
- : 'CLI mode selected, but Claude CLI is not available yet.');
- } else {
- hint = hasApiKey
- ? 'API Key mode selected. Requests will use the configured Anthropic key directly.'
- : (cliAvailable
- ? 'API Key mode selected, but no key is configured. Runtime will fall back to CLI.'
- : 'API Key mode selected, but no key is configured yet.');
- }
- $('settingsProviderHint').textContent = hint;
- }
- // ===================== INIT =====================
- async function init() {
- const entered = sessionStorage.getItem('vlcode_entered');
- let cloudLoggedIn = false;
- try {
- const cs = await api('/api/cloud/status');
- if (cs.loggedIn) {
- _cloudConnected = true;
- cloudLoggedIn = true;
- }
- } catch {}
- if (!isDesktopApp() && !entered && !cloudLoggedIn) {
- $('landingOverlay').classList.add('active');
- checkCliStatus();
- refreshLandingDocsFrame();
- return;
- }
- sessionStorage.setItem('vlcode_entered', '1');
- await initIDE();
- }
- async function initIDE() {
- syncDesktopWorkspaceUI();
- const settings = await api('/api/settings');
- applyDocIdSettings(settings);
- updateLlmBadge(settings.effectiveProvider || settings.llmProvider || 'cli');
- setInternalFilesVisible(localStorage.getItem('vl-code-show-internal') === '1', { reload: false, persist: false });
- const proj = await api('/api/project');
- currentWorkDir = proj.workDir || '';
- if (proj.port) currentPort = proj.port;
- if (proj.version) $('appVersion').textContent = 'v' + proj.version;
- $('chatModel').textContent = shortModel(proj.model);
- $('modelLabel').textContent = shortModel(proj.model);
- // Determine if a valid VL workspace is loaded
- const hasVLProject = proj.isVL && proj.summary?.totalFiles > 0;
- if (hasVLProject) {
- // VL project loaded — show project info and file tree
- const wsName = proj.summary?.projectName || path_basename(proj.workDir);
- $('projectInfo').textContent = `${proj.summary.totalFiles} files`;
- if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
- _tabWorkspaceName = wsName;
- setTabStatus(_tabStatus);
- await loadFileTree();
- } else if (currentWorkDir) {
- // Non-VL workspace selected — still show file tree and workspace name
- const wsName = path_basename(currentWorkDir);
- $('projectInfo').textContent = 'Workspace';
- if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
- _tabWorkspaceName = wsName;
- setTabStatus(_tabStatus);
- await loadFileTree();
- } else {
- // No workspace at all — show "Open File" prompt
- $('projectInfo').textContent = '';
- if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
- _tabWorkspaceName = '';
- $('fileTree').innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a project workspace from the top-left</div>';
- }
- await loadWorkspaces();
- // Show workspace selector on startup if no workspace loaded at all
- if (!currentWorkDir) {
- resetConversationState();
- $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
- $('chatInput').disabled = false;
- $('chatSend').disabled = false;
- }
- await checkCloudLoginStatus();
- if (currentWorkDir) {
- await Promise.all([
- loadPreviewUrlsFromProfile(),
- loadCloudGid(),
- ]);
- }
- updateContext();
- connectSSE();
- setupImagePaste();
- if (currentWorkDir) {
- // Restore chat state from backend (single source of truth)
- const chatStateRestored = await fetchChatStateFromServer();
- if (!chatStateRestored) {
- loadChatState();
- }
- // Restore AI session context (messages, todos) from backend
- try {
- const chatId = activeConvId ?? 0;
- const sessStatus = await api(`/api/session/${chatId}/status`);
- if (sessStatus?.restored && sessStatus.messageCount > 0) {
- console.log(`[Session] Restored ${sessStatus.messageCount} messages from ${sessStatus.source} (${sessStatus.turnCount} turns)`);
- if (sessStatus.todos?.length) renderTodos(sessStatus.todos);
- }
- } catch {}
- // Restore workspace UI state (files, mode — NOT chat)
- if (hasVLProject) {
- await restoreWorkspaceState();
- }
- } else {
- resetConversationState();
- switchMode('docs');
- }
- // Unified save: push chat state to backend every 10s
- setInterval(pushChatStateToServer, 10000);
- // Save workspace state (non-chat: files, mode) every 30s
- setInterval(saveWorkspaceState, 30000);
- // Keep window tab bar in sync with running instances
- setInterval(renderWsTabs, 5000);
- // Save state before page unload
- window.addEventListener('beforeunload', () => {
- // Push chat state to backend via sendBeacon
- const cur = conversations.find(c => c.id === activeConvId);
- if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
- const chatState = {
- conversations: conversations.map(c => ({
- id: c.id, name: c.name,
- dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
- })),
- activeConvId,
- convIdCounter,
- };
- navigator.sendBeacon('/api/chat/state', new Blob([JSON.stringify(chatState)], { type: 'application/json' }));
- // Also save to localStorage as offline fallback
- saveChatState();
- // Workspace state (non-chat fields)
- const wsState = {
- savedAt: Date.now(),
- mode: currentMode || 'code',
- activeFile: currentFile || null,
- openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
- debugPanelOpen: $('debugPanel')?.style.display !== 'none',
- chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
- chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
- showInternalFiles,
- wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
- };
- navigator.sendBeacon('/api/workspace/state', new Blob([JSON.stringify(wsState)], { type: 'application/json' }));
- });
- }
- async function loadProjectInfo() {
- const proj = await api('/api/project');
- const vlCount = proj.summary?.totalFiles || 0;
- const hasVL = proj.isVL && vlCount > 0;
- $('chatModel').textContent = shortModel(proj.model);
- if (proj.version) $('appVersion').textContent = 'v' + proj.version;
- $('modelLabel').textContent = shortModel(proj.model);
- currentWorkDir = proj.workDir || '';
- if (hasVL) {
- const wsName = proj.summary?.projectName || path_basename(proj.workDir);
- $('projectInfo').textContent = `${vlCount} files`;
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
- if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
- _tabWorkspaceName = wsName;
- setTabStatus(_tabStatus);
- } else if (currentWorkDir) {
- const wsName = path_basename(currentWorkDir);
- $('projectInfo').textContent = 'Workspace';
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
- if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
- _tabWorkspaceName = wsName;
- setTabStatus(_tabStatus);
- } else {
- $('projectInfo').textContent = '';
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
- if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
- _tabWorkspaceName = '';
- setTabStatus(_tabStatus);
- $('chatInput').disabled = false;
- $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
- $('chatSend').disabled = false;
- }
- // Disable compile button for non-VL workspaces
- const compileBtn = $('compileBtn');
- if (compileBtn) {
- if (!proj.isVL) {
- compileBtn.style.opacity = '0.4';
- compileBtn.title = 'No VL files in current workspace';
- } else {
- compileBtn.style.opacity = '1';
- compileBtn.title = 'Compile & Preview';
- }
- }
- renderWsTabs();
- }
- function shortModel(m) {
- if (m?.includes('opus')) return 'Opus 4.6';
- if (m?.includes('sonnet')) return 'Sonnet 4.6';
- if (m?.includes('haiku')) return 'Haiku 4.5';
- return m || '';
- }
- // ===================== SETUP KEY =====================
- // Landing page enter key handlers
- $('landingApiKey')?.addEventListener('keydown', e => { if (e.key === 'Enter') enterIDE(); });
- $('landingPassword')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingEnterpriseLogin(); });
- $('landingDirectCookie')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingTokenLogin(); });
- // ===================== WORKSPACE (MULTI-WINDOW) =====================
- let _wsInstances = []; // running instances: { port, workDir, pid, startedAt }
- function isDesktopApp() {
- return !!window.vlcodeDesktop?.isElectron;
- }
- function syncDesktopWorkspaceUI() {
- const openFolderBtn = $('wsOpenFolderBtn');
- const pickLocationBtn = $('newProjectLocationPickBtn');
- document.body.classList.toggle('desktop-app', isDesktopApp());
- if (openFolderBtn) openFolderBtn.style.display = isDesktopApp() ? '' : 'none';
- if (pickLocationBtn) pickLocationBtn.style.display = isDesktopApp() ? '' : 'none';
- }
- /** Render current workspace in the header */
- async function renderWsTabs() {
- _renderWsTabsDom();
- }
- /** Kept for SSE compat — instances list is now authoritative */
- function renderWsTabsFromData() { renderWsTabs(); }
- function _renderWsTabsDom() {
- const container = $('wsTabs');
- if (!container) return;
- container.innerHTML = '';
- const hasWorkspace = !!currentWorkDir;
- const el = document.createElement('button');
- el.type = 'button';
- el.className = 'ws-current' + (hasWorkspace ? '' : ' empty');
- el.title = hasWorkspace ? currentWorkDir : 'No workspace selected';
- el.onclick = toggleWsPopover;
- el.innerHTML = `<span class="ws-current-icon">${hasWorkspace ? '●' : '○'}</span><span class="ws-current-name" id="wsCurrentName">${escapeHtml(hasWorkspace ? path_basename(currentWorkDir) : 'Open Workspace')}</span>`;
- container.appendChild(el);
- setWorkspaceTriggerHighlight(!hasWorkspace);
- }
- /** Close a window instance by port */
- async function closeWindowInstance(port, isCurrent) {
- try {
- await fetch(`/api/windows/${port}`, { method: 'DELETE' });
- if (isCurrent) {
- window.close();
- } else {
- await renderWsTabs();
- }
- } catch (e) { console.error('closeWindowInstance error:', e); }
- }
- /** Open a workspace in a new browser window */
- async function openWorkspaceInNewWindow(dirPath) {
- $('wsPopover').classList.remove('open');
- setStatus('Opening new window...', 'yellow');
- try {
- if (isDesktopApp() && window.vlcodeDesktop?.openWorkspaceWindow) {
- await window.vlcodeDesktop.openWorkspaceWindow({ dirPath });
- setStatus('Ready', 'green');
- return;
- }
- const data = await api('/api/windows/open', { method: 'POST', body: JSON.stringify({ dirPath }) });
- if (data.url) {
- const popup = window.open(data.url, `vlcode_${data.port}`);
- if (!popup) {
- addMsg('assistant', `**Workspace ready:** ${data.url}\n\nYour browser blocked the popup. Open this URL manually, or use the Electron app for native multi-window support.`);
- setStatus('Popup blocked by browser', 'red');
- return;
- }
- }
- // refresh tab bar after a short delay (new process needs time to register)
- setTimeout(renderWsTabs, 2000);
- setStatus('Ready', 'green');
- } catch (e) { setStatus('Failed to open window: ' + e.message, 'red'); }
- }
- async function loadWorkspaces() {
- try {
- const data = await api('/api/workspaces');
- // API returns a plain array; mark active entry by comparing to currentWorkDir
- const workspaces = Array.isArray(data) ? data : (data.workspaces || []);
- const wsWithActive = workspaces.map(w => ({ ...w, active: w.path === currentWorkDir }));
- renderWsListInPopover(wsWithActive);
- const active = wsWithActive.find(w => w.active);
- const name = active ? active.name : (currentWorkDir ? path_basename(currentWorkDir) : '');
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = name;
- // Update workspace tabs
- await renderWsTabs();
- } catch {}
- }
- function renderWsListInPopover(workspaces) {
- const list = $('wsList');
- list.innerHTML = '';
- for (const ws of workspaces) {
- const isCurrent = ws.path === currentWorkDir;
- const div = document.createElement('div');
- div.className = 'ws-item' + (isCurrent ? ' active' : '');
- div.innerHTML = `<div><div class="ws-item-name">${escapeHtml(ws.name)}</div><div class="ws-item-path">${escapeHtml(ws.path)}</div></div><span class="ws-del" onclick="event.stopPropagation();deleteWorkspace('${ws.id}')">×</span>`;
- if (isCurrent) {
- // Already open in this window — no action
- div.title = 'Current window';
- } else {
- div.onclick = () => switchWorkspace(ws.path);
- div.title = 'Open in this window';
- }
- list.appendChild(div);
- }
- }
- function setInternalFilesVisible(visible, { reload = true, persist = true } = {}) {
- showInternalFiles = !!visible;
- const btn = $('toggleInternalFilesBtn');
- if (btn) {
- btn.classList.toggle('active', showInternalFiles);
- btn.title = showInternalFiles
- ? 'Hide internal files and generated artifacts'
- : 'Show internal files and generated artifacts';
- }
- if (persist) localStorage.setItem('vl-code-show-internal', showInternalFiles ? '1' : '0');
- if (reload && currentWorkDir) loadFileTree();
- }
- function toggleInternalFiles() {
- setInternalFilesVisible(!showInternalFiles);
- }
- function toggleWsPopover() {
- const pop = $('wsPopover');
- pop.classList.toggle('open');
- if (pop.classList.contains('open') && !_browseCurrentDir) {
- const startDir = currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '';
- browseDir(startDir || '');
- }
- }
- document.addEventListener('click', e => {
- if (!e.target.closest('.ws-popover') && !e.target.closest('.ws-current')) $('wsPopover').classList.remove('open');
- if (!e.target.closest('.mention-dropdown') && !e.target.closest('#chatInput')) $('mentionDropdown').classList.remove('open');
- if (!e.target.closest('.ca-menu')) closeChatMoreMenu();
- });
- // ===================== NEW VL PROJECT =====================
- function toggleNewProjectForm() {
- const form = $('wsNewProjectForm');
- const visible = form.style.display !== 'none';
- form.style.display = visible ? 'none' : 'block';
- if (!visible) {
- // Set location to current browse directory or parent of workDir
- const loc = _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
- $('newProjectLocation').value = loc || '';
- $('newProjectName').value = '';
- $('newProjectError').style.display = 'none';
- $('newProjectName').focus();
- }
- }
- async function pickNewProjectLocation() {
- if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) return;
- try {
- const defaultPath = $('newProjectLocation').value.trim() || _browseCurrentDir || currentWorkDir || '';
- const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath });
- if (!picked?.canceled && picked?.path) {
- $('newProjectLocation').value = picked.path;
- }
- } catch (e) {
- $('newProjectError').textContent = e.message || 'Failed to choose location';
- $('newProjectError').style.display = 'block';
- }
- }
- async function openWorkspacePicker() {
- if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) {
- browseDir(_browseCurrentDir || '');
- return;
- }
- try {
- const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath: _browseCurrentDir || currentWorkDir || '' });
- if (!picked?.canceled && picked?.path) {
- await switchWorkspace(picked.path);
- }
- } catch (e) {
- setStatus('Failed to choose folder: ' + (e.message || e), 'red');
- }
- }
- async function createNewProject() {
- const name = $('newProjectName').value.trim();
- if (!name) { $('newProjectError').textContent = 'Project name is required'; $('newProjectError').style.display = 'block'; return; }
- let parentDir = $('newProjectLocation').value.trim() || _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
- if (!parentDir && isDesktopApp()) {
- await pickNewProjectLocation();
- parentDir = $('newProjectLocation').value.trim();
- }
- if (!parentDir) { $('newProjectError').textContent = 'Browse to a location first'; $('newProjectError').style.display = 'block'; return; }
- try {
- $('newProjectError').style.display = 'none';
- const res = await fetch('/api/workspaces/create-project', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ parentDir, projectName: name })
- });
- const data = await res.json();
- if (!res.ok) { $('newProjectError').textContent = data.error || 'Failed'; $('newProjectError').style.display = 'block'; return; }
- // Open new project in the current window
- $('wsNewProjectForm').style.display = 'none';
- $('wsPopover').classList.remove('open');
- await switchWorkspace(data.path);
- addMsg('assistant', `**New VL project created:** ${data.name}\nLocation: ${data.path}`);
- } catch (e) {
- $('newProjectError').textContent = e.message || 'Failed to create project';
- $('newProjectError').style.display = 'block';
- }
- }
- // ===================== FILE TREE DRAG & DROP =====================
- function handleFileTreeDragOver(e) {
- e.preventDefault();
- e.stopPropagation(); // prevent global drop overlay
- e.dataTransfer.dropEffect = 'copy';
- const overlay = $('sidebarDropOverlay');
- if (overlay) overlay.style.display = 'block';
- }
- function handleFileTreeDragLeave(e) {
- const overlay = $('sidebarDropOverlay');
- // Only hide if truly leaving the file tree (not entering a child)
- const tree = e.currentTarget;
- if (!tree.contains(e.relatedTarget)) {
- if (overlay) overlay.style.display = 'none';
- }
- }
- async function handleFileTreeDrop(e) {
- e.preventDefault();
- e.stopPropagation(); // prevent global drop overlay
- dragCounter = 0; $('dropOverlay').classList.remove('active'); // ensure global overlay clears
- const overlay = $('sidebarDropOverlay');
- if (overlay) overlay.style.display = 'none';
- const items = e.dataTransfer.items;
- if (!items || items.length === 0) return;
- const filesToUpload = [];
- // Accept all common code/text file types — preserve real folder structure
- const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
- // Process dropped items — preserve original folder structure
- const readEntries = async (entry, basePath) => {
- if (entry.isFile) {
- return new Promise((resolve) => {
- entry.file(f => {
- // Accept files matching code extensions or without extension (Makefile, Dockerfile, etc.)
- const ext = f.name.includes('.') ? '.' + f.name.split('.').pop().toLowerCase() : '';
- if (ext && !codeExts.includes(ext)) { resolve(); return; }
- const reader = new FileReader();
- reader.onload = () => {
- // Preserve the real relative path from the drop — no auto-mapping
- const relPath = basePath ? basePath + '/' + f.name : f.name;
- filesToUpload.push({ path: relPath, content: reader.result });
- resolve();
- };
- reader.readAsText(f);
- });
- });
- } else if (entry.isDirectory) {
- const dirReader = entry.createReader();
- return new Promise((resolve) => {
- dirReader.readEntries(async (entries) => {
- for (const sub of entries) {
- await readEntries(sub, basePath ? basePath + '/' + entry.name : entry.name);
- }
- resolve();
- });
- });
- }
- };
- setStatus('Importing dropped files...', 'yellow');
- try {
- const promises = [];
- for (let i = 0; i < items.length; i++) {
- const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
- if (entry) promises.push(readEntries(entry, ''));
- }
- await Promise.all(promises);
- if (filesToUpload.length === 0) {
- setStatus('No files to import', 'yellow');
- return;
- }
- // Upload files to server
- const res = await fetch('/api/upload-folder', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ files: filesToUpload })
- });
- const data = await res.json();
- if (data.ok) {
- await loadFileTree();
- setStatus(`Imported ${data.filesWritten} file(s)`, 'green');
- addMsg('assistant', `**Imported ${data.filesWritten} file(s)** via drag & drop:\n${data.paths.map(p => ' - ' + p).join('\n')}`);
- } else {
- setStatus(data.error || 'Import failed', 'red');
- }
- } catch (e) {
- setStatus('Drop import error: ' + e.message, 'red');
- }
- }
- async function addWorkspace() {
- const dirPath = $('wsAddPath').value.trim();
- if (!dirPath) return;
- $('wsAddPath').value = '';
- // If user typed a path, try browsing to it first; if valid dir, navigate the browser
- try {
- const data = await api(`/api/browse-dir?path=${encodeURIComponent(dirPath)}`);
- if (data.current) {
- browseDir(data.current);
- return;
- }
- } catch {}
- // Fallback: open in this window
- await switchWorkspace(dirPath);
- }
- async function switchWorkspace(dirPath) {
- setStatus('Switching workspace...', 'yellow');
- $('wsPopover').classList.remove('open');
- try {
- // 1. Save current workspace's chat history before leaving
- saveChatState();
- const oldWorkDir = currentWorkDir;
- const switchRes = await fetch('/api/workspaces/switch', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ dirPath }) });
- if (!switchRes.ok) { const err = await switchRes.json().catch(() => ({})); throw new Error(err.error || 'Server switch failed'); }
- // 2. Close all open file tabs (they belong to old workspace)
- openFiles.clear();
- currentFile = null;
- renderTabs();
- $('editor').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- $('iframeContainer').style.display = 'none';
- $('editorPlaceholder').style.display = 'block';
- $('currentFile').textContent = '';
- // 3. Destroy all cached iframes (metadata/workflow from old workspace)
- const container = $('iframeContainer');
- container.innerHTML = '';
- // 4. Switch back to code mode
- currentMode = 'code';
- document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === 'code'));
- // 5. Reload everything from the new workspace
- await loadProjectInfo();
- await loadFileTree();
- await loadWorkspaces();
- await refreshDocIdSettings();
- updateContext();
- // 5b. Reload preview URLs and GID for the new workspace
- previewUrls = {};
- $('previewUrlsPanel').style.display = 'none';
- $('previewUrlsList').innerHTML = '';
- $('previewUrlLabel').textContent = '';
- if ($('cloudGid')) $('cloudGid').value = '';
- loadPreviewUrlsFromProfile();
- loadCloudGid();
- // 6. Restore chat history from backend (or start fresh)
- resetConversationState();
- const wsRestored = await fetchChatStateFromServer();
- if (!wsRestored) loadChatState(); // offline fallback
- // 6b. Re-enable chat input (may have been disabled when no workspace was selected)
- $('chatInput').disabled = false;
- $('chatInput').placeholder = 'Describe changes, @mention files, /s...';
- $('chatSend').disabled = false;
- // 7. Auto-open the first VL file in the new workspace
- autoOpenFirstFile();
- setStatus('Ready', 'green');
- } catch(e) { console.error('switchWorkspace error:', e); setStatus('Switch failed: ' + (e.message || e), 'red'); }
- }
- /** Close current workspace — return to "Open File" initial state */
- async function closeWorkspace() {
- $('wsPopover').classList.remove('open');
- try {
- await fetch('/api/workspaces/close', { method:'POST' });
- } catch {}
- // Clear editor
- openFiles.clear();
- currentFile = null;
- renderTabs();
- $('editor').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- $('iframeContainer').style.display = 'none';
- $('iframeContainer').innerHTML = '';
- $('editorPlaceholder').style.display = 'block';
- $('currentFile').textContent = '';
- previewUrls = {};
- $('previewUrlsPanel').style.display = 'none';
- $('previewUrlsList').innerHTML = '';
- $('previewUrlLabel').textContent = '';
- if ($('cloudGid')) $('cloudGid').value = '';
- // Reset workspace display
- $('projectInfo').textContent = '';
- if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
- if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
- _tabWorkspaceName = '';
- setTabStatus(_tabStatus);
- // Clear file tree
- $('fileTree').innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a VL project workspace from the top-left</div>';
- currentWorkDir = '';
- resetConversationState();
- $('chatInput').disabled = false;
- $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
- $('chatSend').disabled = false;
- renderWsTabs();
- await refreshDocIdSettings();
- switchMode('docs');
- setStatus('Workspace closed', 'green');
- }
- async function deleteWorkspace(id) {
- await fetch(`/api/workspaces/${id}`, { method:'DELETE' });
- await loadWorkspaces();
- }
- function path_basename(p) { return p ? p.split('/').pop() || p : 'No workspace'; }
- function toPascalProjectName(raw) {
- if (!raw) return '';
- const cleaned = String(raw)
- .replace(/\.[^.]+$/, '')
- .replace(/[`"'“”‘’]/g, ' ')
- .replace(/[^A-Za-z0-9]+/g, ' ')
- .trim();
- if (!cleaned) return '';
- const merged = cleaned.split(/\s+/).filter(Boolean)
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
- .join('');
- return /^[A-Z][A-Za-z0-9]*$/.test(merged) ? merged : '';
- }
- async function ensureWorkspaceForImport(suggestedName) {
- if (currentWorkDir) return true;
- const projectName = toPascalProjectName(suggestedName) || `ImportedProject${Date.now().toString().slice(-6)}`;
- setStatus(`Creating workspace ${projectName}...`, 'yellow');
- const res = await fetch('/api/workspaces/create-project', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ projectName }),
- });
- const data = await res.json();
- if (!res.ok || !data.path) throw new Error(data.error || 'Create workspace failed');
- await switchWorkspace(data.path);
- return true;
- }
- // ===================== VL REFERENCE DOCS =====================
- async function loadVLDocs() {
- try {
- const data = await api('/api/vl-docs');
- renderVLDocs(data.docs || []);
- } catch {}
- }
- function renderVLDocs(docs) {
- const list = $('vlDocsList');
- if (!list) return;
- if (!docs.length) {
- list.innerHTML = '<div style="padding:4px 12px;font-size:10px;color:var(--text2);">No docs cached. Click ↻ to sync from DocCenter.</div>';
- return;
- }
- list.innerHTML = '';
- for (const doc of docs) {
- const el = document.createElement('div');
- el.className = 'pc-file';
- el.title = doc.path || doc.name;
- el.innerHTML = `<span onclick="viewVLDoc(${doc.id})" style="cursor:pointer;flex:1;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(doc.name)}</span>` +
- `<button class="pc-doc-toggle ${doc.active ? 'active' : ''}" onclick="event.stopPropagation();toggleVLDoc(${doc.id},${!doc.active})" title="${doc.active ? 'Active (in AI context)' : 'Inactive (not in AI context)'}">` +
- `${doc.active ? '●' : '○'}</button>`;
- list.appendChild(el);
- }
- }
- async function syncVLDocs() {
- setStatus('Syncing VL docs from DocCenter...', 'yellow');
- try {
- const res = await fetch('/api/vl-docs/sync', { method: 'POST' });
- const data = await res.json();
- if (data.error) { setStatus('Sync failed: ' + data.error, 'red'); return; }
- renderVLDocs(data.docs || []);
- $('vlDocsList').style.display = 'block';
- setStatus(`Synced ${data.synced} VL docs`, 'green');
- } catch (e) { setStatus('Sync failed: ' + e.message, 'red'); }
- }
- async function toggleVLDoc(docId, active) {
- try {
- await fetch('/api/vl-docs/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ docId, active }) });
- await loadVLDocs();
- } catch {}
- }
- async function viewVLDoc(docId) {
- try {
- const data = await api('/api/vl-docs');
- const doc = (data.docs || []).find(d => d.id === docId);
- if (!doc) return;
- // Open doc content in editor as read-only preview
- const res = await fetch(`/api/vl-docs/content?file=${encodeURIComponent(doc.file)}`);
- const content = await res.text();
- addMsg('assistant', `**${doc.name}**\n\`\`\`\n${content.substring(0, 2000)}\n\`\`\`${content.length > 2000 ? '\n...(truncated)' : ''}`);
- } catch {}
- }
- // ===================== DIRECTORY BROWSER =====================
- let _browseCurrentDir = '';
- async function browseDir(dirPath) {
- try {
- const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
- const data = await api(`/api/browse-dir${params}`);
- _browseCurrentDir = data.current;
- $('browsePath').textContent = data.current;
- $('browsePath').title = data.current;
- const list = $('browseList');
- list.innerHTML = '';
- if (!data.dirs.length) {
- list.innerHTML = '<div style="padding:8px 12px;font-size:10px;color:var(--text2);">No subdirectories</div>';
- return;
- }
- for (const d of data.dirs) {
- const div = document.createElement('div');
- div.className = 'ws-browse-item' + (d.isVL ? ' is-vl' : '');
- div.innerHTML = `<span class="dir-icon">${d.isVL ? '◆' : '📁'}</span><span class="dir-name">${escapeHtml(d.name)}</span>${d.isVL ? '<span class="dir-vl">VL</span>' : ''}`;
- div.onclick = (e) => { e.stopPropagation(); browseDir(d.path); };
- div.ondblclick = (e) => { e.stopPropagation(); switchWorkspace(d.path); };
- div.title = d.path + (d.isVL ? ' (VL Project — double-click to open in this window)' : ' (click to browse, double-click to open in this window)');
- list.appendChild(div);
- }
- } catch (e) {
- $('browseList').innerHTML = `<div style="padding:8px 12px;font-size:10px;color:var(--red);">Error: ${escapeHtml(e.message || 'Failed to browse')}</div>`;
- }
- }
- function browseDirUp() {
- if (!_browseCurrentDir) return;
- const parent = _browseCurrentDir.split('/').slice(0, -1).join('/') || '/';
- browseDir(parent);
- }
- function selectBrowseDir() {
- if (_browseCurrentDir) switchWorkspace(_browseCurrentDir);
- }
- // Directory browser auto-load is now handled inside toggleWsPopover()
- // ===================== RESTART BACKEND =====================
- async function restartBackend() {
- const btn = $('restartBtn');
- if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
- try {
- await fetch('/api/restart', { method: 'POST' });
- } catch (_) { /* connection will drop */ }
- // Poll until server is back
- const poll = async () => {
- for (let i = 0; i < 30; i++) {
- await new Promise(r => setTimeout(r, 1000));
- try {
- const resp = await fetch('/api/health');
- if (resp.ok) {
- if (btn) { btn.disabled = false; btn.textContent = '↻'; }
- appendLog('system', 'Backend restarted successfully.');
- location.reload();
- return;
- }
- } catch (_) { /* still down */ }
- }
- if (btn) { btn.disabled = false; btn.textContent = '↻'; }
- appendLog('error', 'Backend restart timed out. Please restart manually.');
- };
- poll();
- }
- // ===================== SETTINGS =====================
- const DOC_REF_PREFIX = 'vl://doc/';
- let _docCenterFocusDocId = null;
- function normalizeDocRefInput(value) {
- if (typeof value === 'number') {
- return Number.isInteger(value) && value > 0 ? value : null;
- }
- if (typeof value !== 'string') return null;
- const trimmed = value.trim();
- if (!trimmed) return null;
- if (/^\d+$/.test(trimmed)) {
- const n = parseInt(trimmed, 10);
- return Number.isInteger(n) && n > 0 ? n : null;
- }
- if (trimmed.toLowerCase().startsWith(DOC_REF_PREFIX)) {
- return normalizeDocRefInput(trimmed.slice(DOC_REF_PREFIX.length));
- }
- const inlineMatch = trimmed.match(/(?:^|[?&#])(?:docId|id|ref)=([^&#]+)/i);
- if (inlineMatch?.[1]) {
- try {
- return normalizeDocRefInput(decodeURIComponent(inlineMatch[1]));
- } catch {
- return normalizeDocRefInput(inlineMatch[1]);
- }
- }
- try {
- const parsed = new URL(trimmed, window.location.origin);
- const docId = parsed.searchParams.get('docId')
- || parsed.searchParams.get('id')
- || parsed.searchParams.get('ref');
- if (docId) return normalizeDocRefInput(docId);
- } catch {}
- return null;
- }
- function formatDocRef(value) {
- const docId = normalizeDocRefInput(value);
- return docId ? `${DOC_REF_PREFIX}${docId}` : '';
- }
- function formatDocHref(value) {
- const docId = normalizeDocRefInput(value);
- return docId ? `/doc-center.html?docId=${docId}` : '';
- }
- function escapeAttr(s) {
- return escapeHtml(String(s || ''))
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
- const DOC_ID_CORE_FIELDS = [
- { alias: 'vlSyntax', label: 'VL Syntax', path: 1 },
- { alias: 'theme', label: 'Theme', path: 2 },
- ];
- const DOC_ID_WORKFLOW_FIELDS = [
- { alias: 'workflow3File', label: '3-File CodeGen', path: 30 },
- { alias: 'workflow6File', label: '6-File CodeGen', path: 60 },
- { alias: 'workflow9File', label: '9-File CodeGen', path: 90 },
- { alias: 'workflowMetaDirect', label: 'MetaDirect', path: 110 },
- { alias: 'workflowAddPage', label: 'Add Page', path: 120 },
- { alias: 'workflowAddService', label: 'Add Service', path: 130 },
- { alias: 'workflowThemeCustomize', label: 'Theme Customize', path: 140 },
- { alias: 'workflowIncrementalUpdate', label: 'Incremental Update', path: 141 },
- { alias: 'workflowCompileFix', label: 'Compile Fix', path: 142 },
- ];
- const DOC_ID_LOCKED_FIELDS = [
- { alias: 'workflowSpec', label: 'Workflow Spec', path: 3 },
- { alias: 'metaSpec', label: 'Meta Spec', path: 4 },
- ];
- const DOC_ID_EDITABLE_FIELDS = [...DOC_ID_CORE_FIELDS, ...DOC_ID_WORKFLOW_FIELDS];
- let _docIdSettingsSnapshot = { docIdOverrides: {}, coreDocIds: {} };
- function docIdInputId(prefix, alias) {
- return `${prefix}DocId_${alias}`;
- }
- function getDocIdValue(settings, alias) {
- const editable = normalizeDocRefInput(settings?.docIdOverrides?.[alias]);
- if (editable) return editable;
- const locked = normalizeDocRefInput(settings?.coreDocIds?.[alias]);
- if (locked) return locked;
- return '';
- }
- function readDocBindingInput(prefix, alias) {
- return $(docIdInputId(prefix, alias))?.value?.trim?.() || '';
- }
- function copyTextValue(value, successText) {
- if (!value) {
- setStatus('Document reference is empty', 'red');
- return;
- }
- if (navigator.clipboard?.writeText) {
- navigator.clipboard.writeText(value)
- .then(() => setStatus(successText, 'green'))
- .catch(() => setStatus('Copy failed', 'red'));
- return;
- }
- const temp = document.createElement('textarea');
- temp.value = value;
- document.body.appendChild(temp);
- temp.select();
- try {
- document.execCommand('copy');
- setStatus(successText, 'green');
- } catch {
- setStatus('Copy failed', 'red');
- } finally {
- temp.remove();
- }
- }
- function copyConfiguredDocRef(prefix, alias, kind = 'ref') {
- const raw = readDocBindingInput(prefix, alias);
- const docId = normalizeDocRefInput(raw);
- if (!docId) {
- setStatus('Invalid document reference', 'red');
- return;
- }
- const value = kind === 'link'
- ? `${window.location.origin}${formatDocHref(docId)}`
- : String(docId);
- copyTextValue(value, kind === 'link' ? 'Doc link copied' : 'Doc ID copied');
- }
- function openConfiguredDoc(prefix, alias) {
- const raw = readDocBindingInput(prefix, alias);
- const docId = normalizeDocRefInput(raw);
- if (!docId) {
- setStatus('Invalid document reference', 'red');
- return;
- }
- _docCenterFocusDocId = docId;
- if (prefix === 'settings') closeSettings();
- switchMode('docs');
- }
- function syncDocBindingCard(input) {
- const card = input?.closest?.('.settings-doc-card');
- if (!card) return;
- const docId = normalizeDocRefInput(input.value);
- const docRef = formatDocRef(docId);
- const docHref = formatDocHref(docId);
- const refEl = card.querySelector('.settings-doc-ref');
- const linkEl = card.querySelector('.settings-doc-link');
- const openBtn = card.querySelector('.settings-doc-action[data-action="open"]');
- const copyBtn = card.querySelector('.settings-doc-action[data-action="copy-id"]');
- const inputEl = card.querySelector('input');
- if (inputEl && docId && /^\s*(vl:\/\/doc\/|\/doc-center\.html\?docId=|https?:)/i.test(input.value)) {
- inputEl.value = String(docId);
- }
- if (refEl) refEl.textContent = docRef || 'Not set';
- if (linkEl) linkEl.textContent = docHref || 'No viewer link yet';
- if (openBtn) openBtn.disabled = !docId;
- if (copyBtn) copyBtn.disabled = !docId;
- }
- function renderDocIdFieldGroup(containerId, prefix, fields, settings, { locked = false } = {}) {
- const container = $(containerId);
- if (!container) return;
- container.innerHTML = fields.map((field) => {
- const value = getDocIdValue(settings, field.alias);
- const docRef = formatDocRef(value);
- const docHref = formatDocHref(value);
- return `
- <label class="settings-doc-card${locked ? ' is-locked' : ''}">
- <span class="settings-doc-header">
- <span class="settings-doc-title">${escapeHtml(field.label)}</span>
- <span class="settings-doc-meta">Slot ${field.path}</span>
- </span>
- <input type="text" id="${docIdInputId(prefix, field.alias)}" placeholder="Doc ID / doc link" value="${escapeAttr(value ? String(value) : '')}" oninput="syncDocBindingCard(this)" ${locked ? 'disabled' : ''}>
- <span class="settings-doc-ref">${docRef ? escapeHtml(docRef) : 'Not set'}</span>
- <span class="settings-doc-link">${docHref ? escapeHtml(docHref) : 'No viewer link yet'}</span>
- <div class="settings-doc-actions">
- <button type="button" class="settings-doc-action" data-action="open" onclick="event.preventDefault();event.stopPropagation();openConfiguredDoc('${prefix}','${field.alias}')" ${value ? '' : 'disabled'}>Open</button>
- <button type="button" class="settings-doc-action" data-action="copy-id" onclick="event.preventDefault();event.stopPropagation();copyConfiguredDocRef('${prefix}','${field.alias}')" ${value ? '' : 'disabled'}>Copy ID</button>
- </div>
- </label>
- `;
- }).join('');
- }
- function applyDocIdSettings(settings = {}) {
- _docIdSettingsSnapshot = {
- docIdOverrides: { ...(settings.docIdOverrides || {}) },
- coreDocIds: { ...(settings.coreDocIds || {}) },
- };
- renderDocIdFieldGroup('settingsDocIdCoreGrid', 'settings', DOC_ID_CORE_FIELDS, settings);
- renderDocIdFieldGroup('settingsDocIdWorkflowGrid', 'settings', DOC_ID_WORKFLOW_FIELDS, settings);
- renderDocIdFieldGroup('settingsDocIdLockedGrid', 'settings', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
- renderDocIdFieldGroup('docIdCoreGrid', 'sidebar', DOC_ID_CORE_FIELDS, settings);
- renderDocIdFieldGroup('docIdWorkflowGrid', 'sidebar', DOC_ID_WORKFLOW_FIELDS, settings);
- renderDocIdFieldGroup('docIdLockedGrid', 'sidebar', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
- }
- function collectDocIdSettings(prefix) {
- const out = {};
- for (const field of DOC_ID_EDITABLE_FIELDS) {
- const raw = readDocBindingInput(prefix, field.alias);
- out[field.alias] = normalizeDocRefInput(raw);
- }
- return out;
- }
- async function refreshDocIdSettings() {
- try {
- const settings = await api('/api/settings');
- applyDocIdSettings(settings);
- return settings;
- } catch {
- return null;
- }
- }
- function toggleDocIdConfigPanel() {
- const body = $('docIdConfigBody');
- if (!body) return;
- body.style.display = body.style.display === 'none' ? 'block' : 'none';
- }
- function toggleDocWorkflowGrid(forceOpen = null) {
- const grid = $('docIdWorkflowGrid');
- const toggle = $('docWorkflowToggle');
- if (!grid || !toggle) return;
- const shouldOpen = forceOpen === null ? grid.style.display === 'none' : !!forceOpen;
- grid.style.display = shouldOpen ? 'flex' : 'none';
- toggle.innerHTML = shouldOpen ? '▼' : '▶';
- }
- async function saveDocIdConfigPanel() {
- const body = {
- docIdOverrides: collectDocIdSettings('sidebar'),
- };
- await fetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const updatedSettings = await refreshDocIdSettings();
- if (updatedSettings) {
- updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
- }
- setStatus('Document IDs saved', 'green');
- }
- async function openSettings() {
- const s = await api('/api/settings');
- $('settingsKey').value = '';
- $('settingsKey').placeholder = 'sk-ant-api03-...';
- $('settingsModel').value = s.model;
- $('settingsMaxTokens').value = s.maxOutputTokens;
- $('settingsWorkDir').value = s.workDir;
- applyDocIdSettings(s);
- // Show cloud connection status
- $('settingsCloudStatus').textContent = _cloudConnected ? 'Connected' : 'Not connected';
- $('settingsCloudStatus').style.color = _cloudConnected ? 'var(--green)' : 'var(--text2)';
- renderProviderSettingsState(s);
- // Load autotest settings
- const at = s.autotest || {};
- $('settingsHeadless').checked = !!at.headless;
- $('settingsUseWorkflow').checked = at.useWorkflowEngine !== false;
- $('settingsParallelBrowsers').value = at.parallelWorkers || 5;
- $('settingsMaxCases').value = at.maxCases || 10;
- $('settingsVersion').textContent = `VL-Code v${$('appVersion')?.textContent?.replace('v','') || '?'}`;
- $('settingsModal').classList.add('open');
- }
- function closeSettings() { $('settingsModal').classList.remove('open'); }
- document.querySelectorAll('input[name="settingsProvider"]').forEach((el) => {
- el.addEventListener('change', () => {
- renderProviderSettingsState({
- ...(_settingsSnapshot || {}),
- llmProvider: getSelectedSettingsProvider(),
- });
- });
- });
- function toggleKeyVisibility() {
- const inp = $('settingsKey');
- inp.type = inp.type === 'password' ? 'text' : 'password';
- }
- async function saveSettings() {
- const body = {};
- const key = $('settingsKey').value.trim();
- if (key) body.apiKey = key;
- body.llmProvider = getSelectedSettingsProvider();
- body.model = $('settingsModel').value;
- body.maxOutputTokens = parseInt($('settingsMaxTokens').value) || 16000;
- body.autotest = {
- headless: $('settingsHeadless').checked,
- useWorkflowEngine: $('settingsUseWorkflow').checked,
- parallelWorkers: parseInt($('settingsParallelBrowsers').value) || 5,
- maxCases: parseInt($('settingsMaxCases').value) || 10,
- };
- body.docIdOverrides = collectDocIdSettings('settings');
- await fetch('/api/settings', {
- method: 'POST', headers: {'Content-Type':'application/json'},
- body: JSON.stringify(body)
- });
- closeSettings();
- await loadProjectInfo();
- // Update LLM provider badge
- const updatedSettings = await refreshDocIdSettings() || await api('/api/settings');
- updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
- setStatus('Settings saved', 'green');
- }
- // ===================== CONTEXT EXCLUSION =====================
- let _lastBackendMsgCount = 0;
- async function toggleMsgContext(btnEl) {
- // Find the parent user message with turn boundaries
- const msgEl = btnEl.closest('.msg');
- if (!msgEl) return;
- // Walk up/down to find the user message that has turnStart/turnEnd
- let turnEl = msgEl;
- if (!turnEl.dataset.turnStart) {
- // This is an assistant msg or tool group — find sibling user msg
- let prev = turnEl.previousElementSibling;
- while (prev && !prev.dataset.turnStart) prev = prev.previousElementSibling;
- if (prev) turnEl = prev;
- else return; // can't find turn boundary
- }
- const startIdx = parseInt(turnEl.dataset.turnStart);
- const endIdx = parseInt(turnEl.dataset.turnEnd);
- if (isNaN(startIdx) || isNaN(endIdx)) return;
- try {
- const res = await fetch('/api/context/toggle-exclude', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ startIdx, endIdx, chatId: activeConvId }),
- });
- const data = await res.json();
- if (!data.ok) return;
- const isExcluded = data.nowExcluded;
- // Mark all messages in this turn visually
- let el = turnEl;
- while (el) {
- if (el.classList.contains('msg') || el.classList.contains('tool-group') || el.classList.contains('thinking-block')) {
- el.classList.toggle('excluded-msg', isExcluded);
- const ctxBtn = el.querySelector('.msg-ctx-toggle');
- if (ctxBtn) ctxBtn.classList.toggle('excluded', isExcluded);
- }
- el = el.nextElementSibling;
- // Stop at the next user message (start of next turn) or end
- if (el?.classList.contains('msg') && el?.querySelector('.label')?.textContent === 'user') break;
- }
- // Update context bar
- if (data.usage) {
- const pct = Math.round(data.usage.usedTokens / data.usage.maxTokens * 100);
- $('ctxLabel').textContent = `${pct}%`;
- $('ctxBar').style.width = pct + '%';
- $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
- }
- } catch (e) {
- console.error('toggleMsgContext failed:', e);
- }
- }
- // ===================== AUTH STATUS (header bar) =====================
- function updateAuthStatus(connected, userName) {
- const dot = $('authDot');
- const label = $('authLabel');
- if (connected) {
- dot.classList.add('ok');
- label.textContent = userName || 'Connected';
- label.className = 'auth-name';
- } else {
- dot.classList.remove('ok');
- label.textContent = 'Not logged in';
- label.className = 'auth-label';
- }
- }
- function onAuthStatusClick() {
- if (_cloudConnected) {
- // Toggle cloud panel to show full status + logout option
- toggleCloudPanel();
- } else {
- openCloudLogin();
- }
- }
- // ===================== CLOUD PLATFORM =====================
- let _cloudConnected = false;
- function toggleCloudPanel() {
- const panel = $('cloudPanel');
- const visible = panel.style.display !== 'none';
- panel.style.display = visible ? 'none' : 'block';
- if (!visible) checkCloudStatus();
- }
- async function checkCloudStatus() {
- try {
- const data = await api('/api/cloud/status');
- if (data.connected) {
- showCloudConnected(data.user);
- } else {
- showCloudDisconnected();
- }
- } catch {
- showCloudDisconnected();
- }
- }
- function normalizeProjectProfile(data) {
- if (!data || typeof data !== 'object') return {};
- return (data.profile && typeof data.profile === 'object') ? data.profile : data;
- }
- function getProfileGid(profile) {
- const gid = profile?.groupId ?? profile?.compileGid ?? '';
- return gid ? String(gid) : '';
- }
- function showCloudConnected(user) {
- _cloudConnected = true;
- $('cloudLoginPrompt').style.display = 'none';
- $('cloudConnected').style.display = 'block';
- $('cloudDot').classList.add('connected');
- $('cloudBtn').classList.add('connected');
- const name = user?.name || user?.nickName || 'User';
- const company = user?.companyName || '';
- $('cloudUserInfo').innerHTML = `<span class="cu-name">${name}</span>` +
- (company ? `<span class="cu-company">${company}</span>` : '');
- // Update header auth status
- updateAuthStatus(true, name + (company ? ` (${company})` : ''));
- // Load GID from profile
- loadCloudGid();
- loadCloudApps();
- }
- function showCloudDisconnected() {
- _cloudConnected = false;
- $('cloudLoginPrompt').style.display = 'block';
- $('cloudConnected').style.display = 'none';
- $('cloudDot').classList.remove('connected');
- $('cloudBtn').classList.remove('connected');
- updateAuthStatus(false);
- }
- async function loadCloudGid() {
- try {
- const profile = normalizeProjectProfile(await api('/api/profile'));
- const gid = getProfileGid(profile);
- if (gid) $('cloudGid').value = gid;
- } catch {}
- }
- async function createCloudProject() {
- const projectName = prompt('Cloud project name:', document.getElementById('sidebarProjectName')?.textContent || 'VLCode-Project');
- if (!projectName) return;
- showCloudSyncStatus('Creating cloud project...', 'syncing');
- try {
- const data = await api('/api/cloud/create-project', { method: 'POST', body: JSON.stringify({ name: projectName }) });
- if (data.error) {
- showCloudSyncStatus('Create failed: ' + data.error, 'error');
- addMsg('assistant', `**Create project failed:** ${data.error}\n\nPlease create a project on the VL platform manually and paste the GID into the Workspace GID field.`);
- return;
- }
- $('cloudGid').value = data.gid;
- // Save GID to Config/ProjectConfig
- await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) });
- showCloudSyncStatus(`Project created (GID: ${data.gid})`, 'ok');
- setStatus(`Cloud project created: GID ${data.gid}`, 'green');
- addMsg('assistant', `**Cloud project created** — GID: \`${data.gid}\`\n\nGID saved to \`Config/ProjectConfig\`. You can now click **Compile** to push files and build.`);
- loadCloudApps();
- } catch (e) {
- showCloudSyncStatus('Error: ' + e.message, 'error');
- }
- }
- async function loadCloudApps() {
- try {
- const data = await api('/api/cloud/apps?limit=20');
- const list = $('cloudAppsList');
- list.innerHTML = '';
- if (!data.apps?.length) {
- list.innerHTML = '<div style="padding:4px 12px;font-size:9px;color:var(--text2);">No cloud apps</div>';
- return;
- }
- for (const app of data.apps) {
- const el = document.createElement('div');
- el.className = 'cloud-app-item';
- el.innerHTML = `<span class="ca-title">${app.title || 'Untitled'}</span><span class="ca-gid">GID:${app.gid}</span>`;
- el.onclick = () => {
- $('cloudGid').value = app.gid;
- setStatus(`Selected cloud workspace: ${app.title} (GID:${app.gid})`, 'green');
- };
- list.appendChild(el);
- }
- } catch {}
- }
- function openCloudLogin() {
- $('cloudLoginError').style.display = 'none';
- $('cloudUsername').value = '';
- $('cloudPassword').value = '';
- $('cloudCompany').value = '';
- $('cloudDirectCookie').value = '';
- switchLoginTab('enterprise');
- $('cloudLoginModal').classList.add('open');
- $('cloudUsername').focus();
- // Load Google Identity Services
- initGoogleSignIn();
- }
- function closeCloudLogin() {
- $('cloudLoginModal').classList.remove('open');
- }
- function switchLoginTab(tab) {
- document.querySelectorAll('.cl-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
- $('clEnterprise').style.display = tab === 'enterprise' ? 'block' : 'none';
- $('clGoogle').style.display = tab === 'google' ? 'block' : 'none';
- $('clToken').style.display = tab === 'token' ? 'block' : 'none';
- $('cloudLoginError').style.display = 'none';
- }
- function showLoginError(msg) {
- $('cloudLoginError').textContent = msg;
- $('cloudLoginError').style.display = 'block';
- }
- // --- Enterprise Login ---
- async function doEnterpriseLogin() {
- const btn = $('cloudLoginBtn');
- btn.disabled = true;
- btn.textContent = 'Logging in...';
- $('cloudLoginError').style.display = 'none';
- try {
- const username = $('cloudUsername').value.trim();
- const password = $('cloudPassword').value;
- const companyName = $('cloudCompany').value.trim();
- if (!username || !password) { showLoginError('Email and password required'); return; }
- const res = await fetch('/api/cloud/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, password, companyName }),
- });
- const data = await res.json();
- if (data.ok) {
- closeCloudLogin();
- showCloudConnected(data.user);
- setStatus('Cloud connected', 'green');
- $('cloudPanel').style.display = 'block';
- } else {
- showLoginError(data.error || 'Login failed');
- }
- } catch (e) {
- showLoginError(e.message);
- } finally {
- btn.disabled = false;
- btn.textContent = 'Login';
- }
- }
- // --- Google Sign-In ---
- const GOOGLE_CLIENT_ID = '877956091268-3kjo5pbn2hptvt8s8q8l82mqlbs2fa3l.apps.googleusercontent.com';
- let _gsiLoaded = false;
- function initGoogleSignIn() {
- if (_gsiLoaded) return;
- // Load Google Identity Services script
- if (!document.getElementById('gsi-script')) {
- const script = document.createElement('script');
- script.id = 'gsi-script';
- script.src = 'https://accounts.google.com/gsi/client';
- script.onload = () => { _gsiLoaded = true; renderGoogleButton(); };
- script.onerror = () => {
- $('googleSignInBtn').style.display = 'none';
- $('googleSignInFallback').style.display = 'block';
- };
- document.head.appendChild(script);
- } else if (window.google?.accounts) {
- renderGoogleButton();
- }
- }
- function renderGoogleButton() {
- try {
- google.accounts.id.initialize({
- client_id: GOOGLE_CLIENT_ID,
- callback: handleGoogleCredential,
- auto_select: false,
- });
- google.accounts.id.renderButton($('googleSignInBtn'), {
- theme: 'filled_black',
- size: 'large',
- width: 300,
- text: 'signin_with',
- });
- $('googleSignInBtn').style.display = 'inline-block';
- $('googleSignInFallback').style.display = 'none';
- } catch (e) {
- console.warn('Google Sign-In render failed:', e);
- $('googleSignInBtn').style.display = 'none';
- $('googleSignInFallback').style.display = 'block';
- }
- }
- async function handleGoogleCredential(response) {
- // response.credential is a JWT ID token
- const statusEl = $('googleLoginStatus');
- statusEl.style.display = 'block';
- statusEl.textContent = 'Authenticating with VL Platform...';
- try {
- // Decode JWT to get user info (base64url decode the payload)
- const payload = JSON.parse(atob(response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
- const googleUser = {
- email: payload.email,
- name: payload.name,
- id: payload.sub,
- picture: payload.picture || '',
- };
- // Call our server to do the platform googleLoginOrRegister
- const res = await fetch('/api/cloud/google-login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(googleUser),
- });
- const data = await res.json();
- if (data.ok) {
- closeCloudLogin();
- showCloudConnected(data.user);
- setStatus('Cloud connected via Google', 'green');
- $('cloudPanel').style.display = 'block';
- } else {
- statusEl.style.color = 'var(--red)';
- statusEl.textContent = data.error || 'Google login failed';
- }
- } catch (e) {
- statusEl.style.color = 'var(--red)';
- statusEl.textContent = 'Error: ' + e.message;
- }
- }
- function googleLoginViaBrowser() {
- window.open('https://www.visuallogic.ai', '_blank');
- switchLoginTab('token');
- }
- // --- Token Login ---
- async function doTokenLogin() {
- const token = $('cloudDirectCookie').value.trim();
- if (!token) { showLoginError('Paste the ih5bearer token value'); return; }
- try {
- await fetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ cookie: token }),
- });
- // Verify
- const status = await api('/api/cloud/status?refresh=true');
- if (status.connected) {
- closeCloudLogin();
- showCloudConnected(status.user);
- setStatus('Cloud connected via token', 'green');
- $('cloudPanel').style.display = 'block';
- } else {
- showLoginError('Token invalid or expired');
- }
- } catch (e) {
- showLoginError(e.message);
- }
- }
- async function cloudLogout() {
- await fetch('/api/cloud/logout', { method: 'POST' });
- showCloudDisconnected();
- setStatus('Cloud disconnected', 'yellow');
- }
- function showCloudSyncStatus(text, type) {
- const el = $('cloudSyncStatus');
- el.style.display = 'block';
- el.className = 'cloud-status ' + (type || '');
- el.textContent = text;
- if (type === 'ok') setTimeout(() => { el.style.display = 'none'; }, 5000);
- }
- async function cloudSyncPush() {
- let gid = $('cloudGid').value.trim();
- // Auto-load GID from project profile if not set in UI
- if (!gid) {
- try {
- const profile = normalizeProjectProfile(await api('/api/profile'));
- gid = getProfileGid(profile);
- if (gid) $('cloudGid').value = gid;
- } catch {}
- }
- if (!gid) {
- setStatus('No GID found — compile first to get one', 'yellow');
- addMsg('assistant', 'No workspace GID. Running compile to create one...');
- await compileProject();
- gid = $('cloudGid').value.trim();
- if (!gid) return;
- }
- showCloudSyncStatus('Pushing files to cloud...', 'syncing');
- try {
- const res = await fetch('/api/cloud/sync/push', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ gid }),
- });
- const data = await res.json();
- if (data.error) {
- showCloudSyncStatus('Push failed: ' + data.error, 'error');
- } else {
- showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
- addMsg('assistant', `**Cloud Push:** ${data.total} files synced to workspace GID:${gid}`);
- }
- } catch (e) {
- showCloudSyncStatus('Push error: ' + e.message, 'error');
- }
- }
- async function cloudSyncPull() {
- const gid = $('cloudGid').value.trim();
- if (!gid) {
- setStatus('Set a Workspace GID first', 'red');
- return;
- }
- showCloudSyncStatus('Pulling files from cloud...', 'syncing');
- try {
- const res = await fetch('/api/cloud/sync/pull', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ gid }),
- });
- const data = await res.json();
- if (data.error) {
- showCloudSyncStatus('Pull failed: ' + data.error, 'error');
- } else {
- showCloudSyncStatus(`Pulled ${data.filesPulled} files`, 'ok');
- addMsg('assistant', `**Cloud Pull:** ${data.filesPulled} files pulled from workspace GID:${gid}`);
- await loadFileTree();
- }
- } catch (e) {
- showCloudSyncStatus('Pull error: ' + e.message, 'error');
- }
- }
- async function cloudCompile() {
- const gid = $('cloudGid').value.trim();
- const btn = $('compileBtn');
- btn.disabled = true;
- btn.innerHTML = '⏳ Compiling...';
- btn.style.opacity = '0.6';
- setStatus('Cloud compile: syncing + compiling...', 'yellow');
- showCloudSyncStatus('Sync + Compile in progress...', 'syncing');
- try {
- const body = {};
- if (gid) body.gid = gid;
- const res = await fetch('/api/cloud/compile', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const data = await res.json();
- if (data.error) {
- setStatus('Cloud compile failed: ' + data.error, 'red');
- showCloudSyncStatus('Compile failed', 'error');
- addMsg('assistant', 'Cloud compile failed: ' + data.error);
- return;
- }
- // Update GID field with the result
- if (data.gid) $('cloudGid').value = data.gid;
- const urls = data.previewUrls || {};
- const keys = Object.keys(urls);
- const errList = data.errList || [];
- const errCount = errList.length;
- if (keys.length > 0) {
- activatePreview(urls);
- const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
- addMsg('assistant', `**Cloud Compile ${errCount > 0 ? 'with ' + errCount + ' error(s)' : 'success'}** (GID: ${data.gid}, synced: ${data.syncedFiles} files)\n\n**Preview URLs:**\n${urlList}`);
- setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile — preview ready', errCount > 0 ? 'yellow' : 'green');
- } else {
- setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile done', errCount > 0 ? 'yellow' : 'green');
- }
- showCloudSyncStatus(`Compiled (${data.syncedFiles} files synced)`, 'ok');
- if (errCount > 0) {
- const errLines = errList.map((e, i) => {
- if (typeof e === 'string') return ` ${i + 1}. ${e}`;
- if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
- return ` ${i + 1}. ${JSON.stringify(e)}`;
- }).join('\n');
- addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
- addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
- }
- // Completion summary
- addMsg('assistant', errCount > 0
- ? `Compile done, ${errCount} error(s). Check the error list and fix them.`
- : `Compile done, no errors.`);
- } catch (e) {
- setStatus('Cloud compile error', 'red');
- showCloudSyncStatus('Error: ' + e.message, 'error');
- addMsg('assistant', 'Cloud compile error: ' + e.message);
- } finally {
- btn.disabled = false;
- btn.innerHTML = '▶ Compile';
- btn.style.opacity = '1';
- }
- }
- // Check cloud status on load
- async function initCloudStatus() {
- try {
- const data = await api('/api/cloud/status');
- if (data.connected) {
- $('cloudPanel').style.display = 'block';
- showCloudConnected(data.user);
- }
- } catch {}
- // Init backend message count for context exclusion
- try {
- const ctx = await api('/api/context/messages', activeConvId);
- _lastBackendMsgCount = ctx.count || 0;
- } catch {}
- }
- /** Check cloud login status on startup — validate cookie, show offline if expired */
- async function checkCloudLoginStatus() {
- try {
- const data = await api('/api/cloud/status?refresh=true');
- if (data.connected) {
- $('cloudPanel').style.display = 'block';
- showCloudConnected(data.user);
- } else {
- showCloudDisconnected();
- if (data.error === 'Session expired') {
- addMsg('assistant', 'Cloud session expired. Please log in again to enable compile & push.');
- }
- }
- } catch {
- showCloudDisconnected();
- }
- // Init backend message count
- try {
- const ctx = await api('/api/context/messages', activeConvId);
- _lastBackendMsgCount = ctx.count || 0;
- } catch {}
- }
- // ===================== FILE TREE =====================
- function formatFileSize(bytes) {
- if (bytes == null) return '';
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- }
- let _fileSizeMap = {}; // path → size in bytes
- const FILE_TREE_CATEGORY_ORDER = ['Apps', 'Sections', 'ExtComponents', 'Services', 'Database', 'Theme', 'Process', 'Config', 'root', '.vl-code'];
- const FILE_TREE_TYPE_ORDER = { app: 0, section: 1, component: 2, service: 3, database: 4, theme: 5, process: 6, config: 7, json: 8, doc: 9, report: 10, workflow: 11, image: 20, log: 21 };
- function getFileTreeCategoryRank(cat) {
- const top = cat === 'root' ? 'root' : cat.split('/')[0];
- const idx = FILE_TREE_CATEGORY_ORDER.indexOf(top);
- const base = idx === -1 ? 90 : idx * 10;
- return base + (cat === top ? 0 : 1);
- }
- function shouldHideFileTreePath(filePath, type) {
- if (!showInternalFiles && filePath.startsWith('.vl-code/sessions/')) return true;
- if (!showInternalFiles && filePath.startsWith('.vl-code/workflows/')) return true;
- if (!showInternalFiles && filePath === '.vl-code/workspace.json') return true;
- if (!showInternalFiles && filePath === '.vl-code/last-compile.json') return true;
- if (/^manual_\d+\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) return true;
- if (type === 'image' && /(?:^|\/)(manual_|screenshot_|screen_)/i.test(filePath)) return true;
- return false;
- }
- function compareFileTreeItems(a, b) {
- const typeDiff = (FILE_TREE_TYPE_ORDER[a.type] ?? 50) - (FILE_TREE_TYPE_ORDER[b.type] ?? 50);
- if (typeDiff !== 0) return typeDiff;
- return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
- }
- async function loadFileTree() {
- try {
- const data = await api('/api/files');
- const tree = $('fileTree');
- if (!currentWorkDir) {
- tree.innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a project workspace from the top-left</div>';
- return;
- }
- tree.innerHTML = '';
- // Rebuild the visible tree client-side so internal artifacts stay out of the way.
- _fileSizeMap = {};
- const grouped = new Map();
- let hiddenCount = 0;
- for (const file of data.files) {
- if (file.size != null) _fileSizeMap[file.path] = file.size;
- const parts = file.path.split('/');
- const cat = parts.length > 1 ? parts.slice(0, -1).join('/') : 'root';
- const name = file.name || parts[parts.length - 1];
- const type = getType(name, cat);
- if (shouldHideFileTreePath(file.path, type)) {
- hiddenCount++;
- continue;
- }
- if (!grouped.has(cat)) grouped.set(cat, []);
- grouped.get(cat).push({ name, path: file.path, type });
- }
- const sorted = [...grouped.entries()].sort((a, b) => {
- const rankDiff = getFileTreeCategoryRank(a[0]) - getFileTreeCategoryRank(b[0]);
- if (rankDiff !== 0) return rankDiff;
- return a[0].localeCompare(b[0], undefined, { numeric: true, sensitivity: 'base' });
- });
- let visibleCount = 0;
- for (const [cat, files] of sorted) {
- files.sort(compareFileTreeItems);
- if (files.length === 0) continue;
- const div = document.createElement('div');
- div.className = 'category';
- div.innerHTML = `<div class="cat-name">${cat === 'root' ? './' : cat + '/'}</div>`;
- for (const file of files) {
- visibleCount++;
- const el = document.createElement('div');
- el.className = 'file';
- el.style.display = 'flex';
- el.style.alignItems = 'center';
- const fp = file.path;
- el.dataset.path = fp;
- const sizeStr = _fileSizeMap[fp] != null ? `<span style="font-size:9px;color:var(--text2);margin-left:auto;padding-left:6px;white-space:nowrap">${formatFileSize(_fileSizeMap[fp])}</span>` : '';
- el.innerHTML = `${getFileIcon(file.type, file.name)}<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${file.name}</span>${sizeStr}`;
- el.onclick = () => openFileOrPreview(fp, file.type);
- el.oncontextmenu = (e) => showFileCtxMenu(e, fp);
- div.appendChild(el);
- }
- tree.appendChild(div);
- }
- $('fileCount').textContent = hiddenCount > 0
- ? `${visibleCount} shown / ${data.files.length} total`
- : `${visibleCount} files`;
- } catch {}
- }
- function getType(name, cat) {
- const ext = name.split('.').pop().toLowerCase();
- const base = name.toLowerCase();
- // VL source types
- const vlType = {vx:'app',sc:'section',cp:'component',vs:'service',vdb:'database',vth:'theme'}[ext];
- if (vlType) return vlType;
- // Image files
- if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return 'image';
- // Reports
- if (base.includes('report') || base.includes('result')) return 'report';
- // Workflows
- if ((cat && cat.includes('workflow')) || base.includes('workflow')) return 'workflow';
- // Log files
- if (ext === 'log' || base.includes('log')) return 'log';
- // Process artifacts
- if ((cat && cat.startsWith('Process')) || base.includes('process')) return 'process';
- // Config files
- if (base === 'conventions.json' || base === 'project.json' || base.includes('config')) return 'config';
- // Standard types
- return {json:'json',md:'doc',txt:'doc',html:'doc',csv:'doc'}[ext] || 'doc';
- }
- /** Get VS Code-style icon for file type */
- function getFileIcon(type, name) {
- const icons = {
- app: '<span class="file-icon" style="color:var(--accent)">◆</span>', // diamond
- section: '<span class="file-icon" style="color:var(--green)">▦</span>', // square
- component: '<span class="file-icon" style="color:var(--yellow)">◈</span>', // nested diamond
- service: '<span class="file-icon" style="color:var(--red)">⚙</span>', // gear
- database: '<span class="file-icon" style="color:var(--text2)">◫</span>', // cylinder-like
- theme: '<span class="file-icon" style="color:var(--purple)">★</span>', // star
- json: '<span class="file-icon" style="color:#e0ad40">{ }</span>',
- doc: '<span class="file-icon" style="color:#9da5ae">☰</span>', // hamburger/lines
- image: '<span class="file-icon" style="color:#f0883e">▢</span>', // frame
- report: '<span class="file-icon" style="color:#3fb950">☑</span>', // ballot check
- log: '<span class="file-icon" style="color:#8b949e">◓</span>', // circle half
- config: '<span class="file-icon" style="color:#d29922">⚙</span>', // gear
- workflow: '<span class="file-icon" style="color:#a371f7">▶</span>', // play
- process: '<span class="file-icon" style="color:#c49bff">✎</span>', // pencil
- };
- return icons[type] || icons.doc;
- }
- // ===================== EDITOR =====================
- async function openFile(fpath) {
- try {
- const data = await api(`/api/file?path=${encodeURIComponent(fpath)}`);
- const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
- // Clear all previous file tabs (no caching) — keep only special tabs
- for (const [k, v] of openFiles) {
- if (v.type === 'file') openFiles.delete(k);
- }
- openFiles.set(fpath, { type: 'file', content });
- currentFile = fpath;
- // Switch to code mode if in meta/flow mode
- if (currentMode !== 'code') switchMode('code');
- renderTabs();
- showTabContent(fpath);
- document.querySelectorAll('.file').forEach(el => el.classList.toggle('active', el.dataset.path === fpath));
- $('currentFile').textContent = fpath;
- } catch (err) {
- console.error('openFile failed:', fpath, err);
- setStatus('Failed to open ' + fpath.split('/').pop(), 'red');
- }
- }
- function openFileOrPreview(fpath, type) {
- if (type === 'image') {
- showImagePreview(fpath);
- } else {
- openFile(fpath);
- }
- }
- function showImagePreview(fpath) {
- let overlay = $('imagePreviewOverlay');
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.id = 'imagePreviewOverlay';
- overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;';
- overlay.onclick = () => overlay.style.display = 'none';
- overlay.innerHTML = `
- <div id="imgPreviewTitle" style="color:#fff;font-size:13px;margin-bottom:8px;"></div>
- <img id="imgPreviewImg" style="max-width:90vw;max-height:80vh;border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,.5);">
- <div id="imgPreviewInfo" style="color:var(--text2);font-size:11px;margin-top:8px;"></div>
- `;
- document.body.appendChild(overlay);
- }
- const url = `/api/file/raw?path=${encodeURIComponent(fpath)}`;
- $('imgPreviewImg').src = url;
- $('imgPreviewTitle').textContent = fpath;
- const size = _fileSizeMap[fpath];
- $('imgPreviewInfo').textContent = size != null ? formatFileSize(size) : '';
- overlay.style.display = 'flex';
- }
- /** Open a special (non-file) tab: workflow DAG or metadata graph */
- function openSpecialTab(key, type, title, data) {
- openFiles.set(key, { type, title, data });
- currentFile = key;
- renderTabs();
- showTabContent(key);
- $('currentFile').textContent = title;
- }
- /** Close a tab by key */
- function closeTab(key, evt) {
- if (evt) evt.stopPropagation();
- openFiles.delete(key);
- // Clean up iframe if it was a special tab
- const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
- if (iframe) iframe.remove();
- // Switch to another tab or show placeholder
- if (currentFile === key) {
- const keys = [...openFiles.keys()];
- if (keys.length > 0) {
- currentFile = keys[keys.length - 1];
- renderTabs();
- showTabContent(currentFile);
- $('currentFile').textContent = currentFile;
- } else {
- currentFile = null;
- renderTabs();
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('iframeContainer').style.display = 'none';
- $('editorPlaceholder').style.display = 'block';
- $('currentFile').textContent = '';
- }
- } else {
- renderTabs();
- }
- }
- function renderTabs() {
- const tabs = $('editorTabs');
- tabs.innerHTML = '';
- // Only show tab bar if there are special (non-file) tabs alongside the current file
- const hasSpecialTabs = [...openFiles.values()].some(v => v.type !== 'file');
- tabs.style.display = (openFiles.size > 1 || hasSpecialTabs) ? 'flex' : 'none';
- for (const [key, info] of openFiles) {
- const tab = document.createElement('div');
- tab.className = 'tab' + (key === currentFile ? ' active' : '');
- const icons = { file: '', workflow: '\u2B21 ', metadata: '\u25C9 ' };
- const label = info.type === 'file' ? key.split('/').pop() : (info.title || key);
- tab.innerHTML = `<span class="tab-icon">${icons[info.type] || ''}</span><span>${escapeHtml(label)}</span><span class="tab-close" onclick="closeTab('${key.replace(/'/g, "\\'")}', event)">×</span>`;
- tab.onclick = () => { currentFile = key; renderTabs(); showTabContent(key); };
- tabs.appendChild(tab);
- }
- }
- /** Show content for the active tab (file editor or iframe) */
- function showTabContent(key) {
- const info = openFiles.get(key);
- if (!info) return;
- $('editorPlaceholder').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- if (info.type === 'file') {
- $('iframeContainer').style.display = 'none';
- // Try CodeMirror, fall back to textarea
- initCodeMirror();
- if (cmEditor) {
- $('editor').style.display = 'none';
- $('cmEditorWrap').style.display = 'block';
- cmEditor.setValue(info.content || '');
- cmEditor.setOption('mode', getCmMode(key));
- cmEditor.clearHistory();
- setTimeout(() => cmEditor.refresh(), 10);
- } else {
- // Textarea fallback
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'block';
- $('editor').value = info.content || '';
- }
- $('currentFile').textContent = key;
- } else {
- // Show iframe, hide code editor
- $('editor').style.display = 'none';
- $('cmEditorWrap').style.display = 'none';
- $('iframeContainer').style.display = 'block';
- // Hide all iframes, show the one for this tab
- const container = $('iframeContainer');
- [...container.children].forEach(f => f.style.display = 'none');
- let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
- if (!iframe) {
- iframe = document.createElement('iframe');
- iframe.dataset.tab = key;
- iframe.sandbox = 'allow-scripts allow-same-origin';
- if (info.type === 'workflow') iframe.src = '/workflow-editor.html';
- else if (info.type === 'metadata') iframe.src = '/metadata-viewer.html';
- iframe.onload = () => {
- // Send data to iframe once ready
- if (info.data) {
- const msg = info.type === 'workflow'
- ? { type: 'loadWorkflow', data: info.data, workflowName: info.workflowName || info.name || null }
- : { type: 'loadMetadata', data: info.data };
- iframe.contentWindow.postMessage(msg, '*');
- }
- };
- container.appendChild(iframe);
- }
- iframe.style.display = 'block';
- }
- }
- // Legacy alias
- function showEditor(content) {
- initCodeMirror();
- $('iframeContainer').style.display = 'none';
- $('editorPlaceholder').style.display = 'none';
- if (cmEditor) {
- $('cmEditorWrap').style.display = 'block';
- $('editor').style.display = 'none';
- cmEditor.setValue(content || '');
- setTimeout(() => cmEditor.refresh(), 10);
- } else {
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'block';
- $('editor').value = content || '';
- }
- }
- $('editor').addEventListener('keydown', e => {
- if ((e.metaKey || e.ctrlKey) && e.key === 's') {
- e.preventDefault();
- saveCurrentFile();
- }
- });
- async function saveCurrentFile() {
- if (!currentFile) return;
- const info = openFiles.get(currentFile);
- if (!info || info.type !== 'file') return; // Only save file tabs
- const content = cmEditor ? cmEditor.getValue() : $('editor').value;
- info.content = content;
- await fetch('/api/file', { method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ path: currentFile, content }) });
- setStatus('Saved ' + currentFile.split('/').pop(), 'green');
- }
- // ===================== DETAIL PANEL =====================
- let _detailEntryCount = 0;
- let _detailManualClosed = false; // When user manually closes, prevent auto-open
- function toggleDetailPanel() {
- const panel = $('detailPanel');
- const wasOpen = panel.classList.contains('open');
- panel.classList.toggle('open');
- // If user is closing it manually, set flag to prevent auto-open
- if (wasOpen) {
- _detailManualClosed = true;
- } else {
- _detailManualClosed = false;
- }
- }
- /** Cross-panel navigation: chat → detail */
- function scrollToDetailEntry(linkId) {
- const panel = $('detailPanel');
- // Open detail panel (even if manually closed — user explicitly clicked)
- if (!panel.classList.contains('open')) {
- panel.classList.add('open');
- _detailManualClosed = false;
- }
- const detailEl = panel.querySelector(`.detail-entry[data-link-id="${linkId}"]`);
- if (detailEl) {
- detailEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
- detailEl.style.outline = '2px solid var(--accent)';
- detailEl.style.background = 'rgba(139,233,253,0.08)';
- setTimeout(() => { detailEl.style.outline = ''; detailEl.style.background = ''; }, 2000);
- }
- }
- /** Cross-panel navigation: detail → chat */
- function scrollToChatEntry(linkId) {
- const chatEl = document.querySelector(`.tool-group[data-link-id="${linkId}"]`);
- if (chatEl) {
- chatEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
- chatEl.style.outline = '2px solid var(--accent)';
- chatEl.style.background = 'rgba(139,233,253,0.08)';
- setTimeout(() => { chatEl.style.outline = ''; chatEl.style.background = ''; }, 2000);
- }
- }
- function clearDetailPanel() {
- $('detailBody').innerHTML = '';
- _detailEntryCount = 0;
- $('detailCount').textContent = '';
- // Also clear backend compile cache so stale data doesn't reappear
- fetch('/api/detail-log', { method: 'DELETE' }).catch(() => {});
- }
- /**
- * Add entry to detail panel.
- * @param {string} phase - Category tag (e.g., 'generate', 'workflow', 'tool')
- * @param {string} message - Main message text
- * @param {*} data - Optional JSON data to display
- * @param {string} type - 'info'|'success'|'error'|'warn'
- * @param {Object} opts - Optional: { depth:0-3, agentId, agentName, parentContainer }
- */
- let _detailLinkId = 0;
- function addDetailEntry(phase, message, data, type = 'info', opts = {}) {
- const panel = $('detailPanel');
- // Only auto-open if user hasn't manually closed it
- if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
- _detailEntryCount++;
- $('detailCount').textContent = `${_detailEntryCount} entries`;
- const body = opts.parentContainer || $('detailBody');
- const now = new Date().toLocaleTimeString();
- const div = document.createElement('div');
- const depth = opts.depth || 0;
- div.className = `detail-entry ${type}` + (depth > 0 ? ` depth-${Math.min(depth, 3)}` : '');
- // Cross-panel linkage: assign ID so chat can scroll to this entry
- if (opts.linkId) {
- div.dataset.linkId = opts.linkId;
- div.style.cursor = 'pointer';
- div.title = 'Click to scroll to chat';
- div.onclick = () => scrollToChatEntry(opts.linkId);
- }
- let html = `<span class="de-time">${now}</span>`;
- if (opts.agentName) html += `<span class="de-agent">[${escapeHtml(opts.agentName)}]</span>`;
- if (phase) html += `<span class="de-phase" data-phase="${escapeHtml(phase)}">[${escapeHtml(phase)}]</span>`;
- html += `<div class="de-msg">${escapeHtml(message)}</div>`;
- if (data) {
- const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
- const isLong = dataStr.length > 200;
- html += `<div class="de-data${isLong ? ' collapsed' : ''}" onclick="event.stopPropagation();this.classList.toggle('collapsed')">${escapeHtml(dataStr)}</div>`;
- }
- div.innerHTML = html;
- body.appendChild(div);
- body.scrollTop = body.scrollHeight;
- return div;
- }
- /**
- * Create or get an agent group in the detail panel for hierarchical display.
- * Returns a container element where child entries can be appended.
- */
- const _detailAgentGroups = {};
- function getOrCreateAgentGroup(agentId, agentName, parentContainer) {
- if (_detailAgentGroups[agentId]) return _detailAgentGroups[agentId];
- const body = parentContainer || $('detailBody');
- const group = document.createElement('div');
- group.className = 'detail-agent-group';
- group.dataset.agentId = agentId;
- group.innerHTML = `
- <div class="detail-agent-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
- <span class="dag-icon">▶</span>
- <span class="dag-name">${escapeHtml(agentName)}</span>
- <span class="dag-desc"></span>
- <span class="dag-status" style="color:var(--yellow);">running</span>
- </div>
- <div class="detail-agent-children"></div>`;
- body.appendChild(group);
- const children = group.querySelector('.detail-agent-children');
- _detailAgentGroups[agentId] = children;
- body.scrollTop = body.scrollHeight;
- return children;
- }
- function completeAgentGroup(agentId, status) {
- const container = _detailAgentGroups[agentId];
- if (!container) return;
- const group = container.closest('.detail-agent-group');
- if (!group) return;
- const statusEl = group.querySelector('.dag-status');
- if (statusEl) {
- statusEl.textContent = status || 'done';
- statusEl.style.color = status === 'error' ? 'var(--red)' : 'var(--green)';
- }
- const icon = group.querySelector('.dag-icon');
- if (icon) icon.textContent = status === 'error' ? '✗' : '✓';
- }
- // Stream box: accumulates streaming content (LLM tokens) in one expandable container
- const _streamBoxes = {};
- function appendToStreamBox(boxId, label, text) {
- const body = $('detailBody');
- const panel = $('detailPanel');
- // Only auto-open if user hasn't manually closed it
- if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
- let box = _streamBoxes[boxId];
- if (!box) {
- const div = document.createElement('div');
- const isThinking = boxId.includes('thinking');
- div.className = 'detail-entry stream-box' + (isThinking ? ' thinking-stream' : '');
- div.innerHTML = `<div class="de-stream-header" onclick="this.parentElement.classList.toggle('collapsed')">
- <span class="de-stream-label">${escapeHtml(label)}</span>
- <span class="de-stream-size">0 chars</span>
- <span class="de-stream-toggle">▼</span>
- </div><div class="de-stream-content"></div>`;
- body.appendChild(div);
- const contentEl = div.querySelector('.de-stream-content');
- const sizeEl = div.querySelector('.de-stream-size');
- box = { el: div, contentEl, sizeEl, charCount: 0 };
- _streamBoxes[boxId] = box;
- }
- box.charCount += text.length;
- // Batch DOM updates: buffer text and flush periodically for smooth rendering
- if (!box._buffer) box._buffer = '';
- box._buffer += text;
- if (!box._flushTimer) {
- box._flushTimer = setTimeout(() => {
- box.contentEl.textContent += box._buffer;
- box._buffer = '';
- box._flushTimer = null;
- box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
- body.scrollTop = body.scrollHeight;
- }, 150); // flush every 150ms — smooth but not jittery
- }
- }
- function flushStreamBoxes() {
- for (const id in _streamBoxes) {
- const box = _streamBoxes[id];
- if (box._buffer) {
- box.contentEl.textContent += box._buffer;
- box._buffer = '';
- if (box._flushTimer) { clearTimeout(box._flushTimer); box._flushTimer = null; }
- box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
- }
- }
- }
- function clearStreamBoxes() {
- flushStreamBoxes();
- for (const id in _streamBoxes) delete _streamBoxes[id];
- }
- // ===================== WORKFLOW STEP CARDS (Detail Log) =====================
- // Enhanced step cards for workflow execution — inputs, outputs, files, re-run
- const _stepCards = {}; // stepID → { el, status, startTime, inputs, outputs, files, thinking, response }
- let _lastWorkflowName = ''; // For rerun
- let _lastRunCheckpoint = null;
- function addStepCard(stepID, type, title, resolvedInputs) {
- const body = $('detailBody');
- const panel = $('detailPanel');
- if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
- const card = document.createElement('div');
- card.className = 'detail-step-card running';
- card.dataset.stepId = stepID;
- // Icon based on type
- const typeIcons = { LLM: '🤖', Write: '📝', Set: '⚙️', Branch: '🔀', Loop: '🔁', Noop: '•', Service: '🔌', API: '🌐', Pause: '⏸', MetaDiff: '📊', ComponentFetch: '📦', ClearFiles: '🗑' };
- const icon = typeIcons[type] || '▶';
- card.innerHTML = `
- <div class="dsc-header" onclick="toggleStepCardBody('${stepID}')" oncontextmenu="showStepCtxMenu(event, '${escapeHtml(stepID)}')">
- <span class="dsc-icon">${icon}</span>
- <span class="dsc-title">${escapeHtml(title || stepID)}</span>
- <span class="dsc-type">${escapeHtml(type || '')}</span>
- <span class="dsc-duration" id="dsc-dur-${stepID}"></span>
- <span class="dsc-hover-actions">
- <button class="dsc-hover-btn" title="Re-run from here" onclick="event.stopPropagation();openRerunDialog('${escapeHtml(stepID)}')">🔄</button>
- <button class="dsc-hover-btn" title="Highlight in DAG" onclick="event.stopPropagation();highlightStepInDAG('${escapeHtml(stepID)}')">🔍</button>
- <button class="dsc-hover-btn" title="Copy outputs" onclick="event.stopPropagation();copyStepOutputs('${escapeHtml(stepID)}')">📋</button>
- </span>
- </div>
- <div class="dsc-body" id="dsc-body-${stepID}"></div>`;
- body.appendChild(card);
- body.scrollTop = body.scrollHeight;
- const state = {
- el: card,
- bodyEl: card.querySelector('.dsc-body'),
- status: 'running',
- startTime: Date.now(),
- type, title, stepID,
- files: [],
- };
- _stepCards[stepID] = state;
- // Add inputs section if available
- if (resolvedInputs) {
- addStepCardSection(stepID, 'Inputs', resolvedInputs);
- }
- _detailEntryCount++;
- $('detailCount').textContent = `${_detailEntryCount} entries`;
- return state;
- }
- function toggleStepCardBody(stepID) {
- const state = _stepCards[stepID];
- if (!state) return;
- state.bodyEl.classList.toggle('open');
- }
- function addStepCardSection(stepID, label, data, collapsed = true) {
- const state = _stepCards[stepID];
- if (!state) return;
- const sec = document.createElement('div');
- sec.className = 'dsc-section';
- const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
- const isLong = dataStr.length > 500;
- const sectionId = `dsc-sec-${stepID}-${label.replace(/\s/g, '')}`;
- sec.innerHTML = `
- <div class="dsc-section-header" onclick="toggleStepCardSection('${sectionId}')">
- <span class="dsc-arrow${collapsed ? '' : ' open'}" id="arr-${sectionId}">▶</span>
- <span>${escapeHtml(label)}</span>
- ${isLong ? '<span style="color:var(--text2);font-size:8px;">(' + (dataStr.length > 1024 ? (dataStr.length / 1024).toFixed(1) + 'KB' : dataStr.length + 'B') + ')</span>' : ''}
- </div>
- <div class="dsc-section-content${collapsed ? '' : ' open'}${isLong ? ' truncated' : ''}" id="${sectionId}">${escapeHtml(isLong ? dataStr.substring(0, 2000) : dataStr)}</div>`;
- state.bodyEl.appendChild(sec);
- }
- function toggleStepCardSection(sectionId) {
- const el = document.getElementById(sectionId);
- const arr = document.getElementById('arr-' + sectionId);
- if (el) el.classList.toggle('open');
- if (arr) arr.classList.toggle('open');
- }
- function completeStepCard(stepID, outputs, selected, duration_ms) {
- const state = _stepCards[stepID];
- if (!state) return;
- state.status = 'done';
- state.el.className = 'detail-step-card done';
- // Update icon
- const iconEl = state.el.querySelector('.dsc-icon');
- if (iconEl) iconEl.textContent = '✓';
- // Duration
- const dur = duration_ms || (Date.now() - state.startTime);
- const durEl = state.el.querySelector('.dsc-duration');
- if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
- // Add outputs section
- if (outputs) {
- addStepCardSection(stepID, 'Outputs', outputs);
- }
- if (selected) {
- addStepCardSection(stepID, 'Branch Selected', selected, false);
- }
- // Add files section if any files were written during this step
- if (state.files.length > 0) {
- const fileList = state.files.map(f => `📄 ${f}`).join('\n');
- addStepCardSection(stepID, `Files (${state.files.length})`, fileList, false);
- }
- // Add re-run button
- const actions = document.createElement('div');
- actions.className = 'dsc-actions';
- actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')">🔄 Re-run from here</button>`;
- state.bodyEl.appendChild(actions);
- // Open body to show results
- state.bodyEl.classList.add('open');
- }
- function errorStepCard(stepID, error, duration_ms) {
- const state = _stepCards[stepID];
- if (!state) return;
- state.status = 'error';
- state.el.className = 'detail-step-card error';
- const iconEl = state.el.querySelector('.dsc-icon');
- if (iconEl) iconEl.textContent = '✗';
- const dur = duration_ms || (Date.now() - state.startTime);
- const durEl = state.el.querySelector('.dsc-duration');
- if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
- addStepCardSection(stepID, 'Error', error, false);
- // Add re-run button even on error (especially useful here)
- const actions = document.createElement('div');
- actions.className = 'dsc-actions';
- actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')" style="border-color:var(--red);color:var(--red);">🔄 Re-run from here</button>`;
- state.bodyEl.appendChild(actions);
- state.bodyEl.classList.add('open');
- }
- /** Track file writes to the current running step card */
- function addFileToStepCard(stepID, filePath) {
- const state = _stepCards[stepID];
- if (!state) return;
- state.files.push(filePath);
- }
- /** Get current running step ID (for file_done association) */
- function getCurrentRunningStepID() {
- for (const [id, s] of Object.entries(_stepCards)) {
- if (s.status === 'running') return id;
- }
- return null;
- }
- // ===================== STEP CARD CONTEXT MENU =====================
- let _stepCtxTarget = null; // stepID of the right-clicked card
- function showStepCtxMenu(e, stepID) {
- e.preventDefault();
- e.stopPropagation();
- _stepCtxTarget = stepID;
- const menu = $('stepCtxMenu');
- menu.style.left = e.clientX + 'px';
- menu.style.top = e.clientY + 'px';
- menu.classList.add('open');
- }
- function _closeStepCtxMenu() { $('stepCtxMenu').classList.remove('open'); }
- document.addEventListener('click', _closeStepCtxMenu);
- function stepCtxRerun() {
- _closeStepCtxMenu();
- if (_stepCtxTarget) openRerunDialog(_stepCtxTarget);
- }
- function stepCtxViewInDAG() {
- _closeStepCtxMenu();
- if (_stepCtxTarget) highlightStepInDAG(_stepCtxTarget);
- }
- function stepCtxCopyOutputs() {
- _closeStepCtxMenu();
- if (_stepCtxTarget) copyStepOutputs(_stepCtxTarget);
- }
- function stepCtxCopyFiles() {
- _closeStepCtxMenu();
- const state = _stepCards[_stepCtxTarget];
- if (!state || !state.files.length) return;
- navigator.clipboard.writeText(state.files.join('\n')).then(
- () => setStatus(`Copied ${state.files.length} file path(s)`, 'green'),
- () => setStatus('Copy failed', 'red')
- );
- }
- function stepCtxToggleBody() {
- _closeStepCtxMenu();
- if (_stepCtxTarget) toggleStepCardBody(_stepCtxTarget);
- }
- function stepCtxExpandAll() {
- _closeStepCtxMenu();
- const state = _stepCards[_stepCtxTarget];
- if (!state) return;
- state.bodyEl.classList.add('open');
- state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.add('open'));
- state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.add('open'));
- }
- function stepCtxCollapseAll() {
- _closeStepCtxMenu();
- const state = _stepCards[_stepCtxTarget];
- if (!state) return;
- state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.remove('open'));
- state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.remove('open'));
- }
- /** Highlight a step in the DAG visualization */
- function highlightStepInDAG(stepID) {
- if (currentMode !== 'flow') switchMode('flow');
- sendToWorkflowIframe({ type: 'highlightNode', nodeId: stepID });
- }
- /** Copy step outputs to clipboard */
- function copyStepOutputs(stepID) {
- const state = _stepCards[stepID];
- if (!state) return;
- // Find outputs section content
- const outputSec = state.bodyEl.querySelector(`#dsc-sec-${stepID}-Outputs`);
- const text = outputSec ? outputSec.textContent : '(no outputs)';
- navigator.clipboard.writeText(text).then(
- () => setStatus('Outputs copied to clipboard', 'green'),
- () => setStatus('Copy failed', 'red')
- );
- }
- // Debug panel removed — all debug info goes to Detail Log
- function debugLog() {} // no-op stub for any remaining calls
- function closeChatMoreMenu() {
- $('chatMoreMenu')?.classList.remove('open');
- }
- function toggleChatMoreMenu(e) {
- e?.stopPropagation();
- const menu = $('chatMoreMenu');
- if (!menu) return;
- menu.classList.toggle('open');
- }
- function chatMenuAction(action) {
- closeChatMoreMenu();
- if (action === 'blueprint') return sendSkillCmd('blueprint');
- if (action === 'search') return openChatSearch();
- if (action === 'compact') return toggleCompactMode();
- if (action === 'settings') return openSettings();
- }
- function toggleCompactMode() {
- const panel = $('chatPanel');
- const isCompact = panel.classList.toggle('compact');
- const menuItem = $('compactMenuItem');
- if (menuItem) menuItem.textContent = isCompact ? 'Full Mode' : 'Compact Mode';
- }
- // ===================== AUTO-SCREENSHOTS =====================
- let _contextScreenshots = []; // Auto-attached to next LLM message
- /** Append screenshot thumbnails to a chat message element */
- function appendScreenshotToChat(msgEl, url, name) {
- if (!msgEl) return;
- let container = msgEl.querySelector('.msg-screenshots');
- if (!container) {
- container = document.createElement('div');
- container.className = 'msg-screenshots';
- msgEl.appendChild(container);
- }
- const item = document.createElement('div');
- item.className = 'ss-item';
- const img = document.createElement('img');
- img.src = url;
- img.onclick = () => window.open(url);
- img.title = name;
- const label = document.createElement('span');
- label.className = 'ss-label';
- label.textContent = name.replace(/^step_/, 'Step ').replace(/_\d+$/, '');
- item.appendChild(img);
- item.appendChild(label);
- container.appendChild(item);
- scrollChat();
- }
- /** Convert blob to base64 data string */
- function blobToBase64(blob) {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result.split(',')[1]);
- reader.readAsDataURL(blob);
- });
- }
- // ===================== CHAT =====================
- let _currentAbortController = null;
- let _chatStartTime = 0;
- let _chatElapsedTimer = null;
- async function stopExecution() {
- // 1. Abort the frontend fetch
- if (_currentAbortController) { _currentAbortController.abort(); _currentAbortController = null; }
- // 2. Tell the server to abort this chat session
- try { await fetch('/api/abort', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) }); } catch {}
- // 3. Finalize all spinning indicators
- finalizeAllToolSpinners();
- clearSpinnerSafetyTimeout();
- // 4. UI cleanup
- $('chatStop').style.display = 'none';
- $('chatSend').style.display = '';
- $('chatSend').disabled = false;
- setChatStatusRunning(false);
- setStatus('Stopped', 'red');
- setTimeout(() => setStatus('Ready', 'green'), 2000);
- }
- function setChatStatusRunning(running) {
- const statusBar = $('chatStatusBar');
- if (running) {
- statusBar.style.display = 'flex';
- _chatStartTime = Date.now();
- updateChatStatusBar('Thinking...', '');
- _chatElapsedTimer = setInterval(updateChatElapsed, 1000);
- setTabStatus('busy');
- } else {
- statusBar.style.display = 'none';
- if (_chatElapsedTimer) { clearInterval(_chatElapsedTimer); _chatElapsedTimer = null; }
- // If tab is not focused, show "new output" indicator instead of idle
- if (!_tabHasFocus) {
- setTabStatus('newOutput');
- } else {
- setTabStatus('idle');
- }
- }
- }
- const _toolVerbs = {
- ReadFile:'Reading', WriteFile:'Writing', EditFile:'Editing', Glob:'Searching files',
- Grep:'Searching code', VLCompile:'Compiling', VLParse:'Compiling', VLValidate:'Validating',
- VLMetadata:'Analyzing metadata', VLSymbols:'Indexing symbols', VLImpact:'Analyzing impact',
- VLAutoFix:'Auto-fixing', VLSyntaxRef:'Looking up syntax', VLCascadeEdit:'Cascade editing',
- SubAgent:'Running agent', AutoTestPipeline:'Running tests', TodoWrite:'Planning',
- AskUserQuestion:'Waiting for input', MetaDiff:'Diffing metadata', SectionDiff:'Diffing sections',
- BrowserNavigate:'Navigating', BrowserClick:'Clicking', BrowserType:'Typing', BrowserScreenshot:'Taking screenshot',
- };
- function updateChatStatusBar(phase, detail) {
- $('csPhase').textContent = phase || '';
- $('csDetail').textContent = detail || '';
- updateChatElapsed();
- }
- function toolToVerb(toolName, input) {
- const verb = _toolVerbs[toolName] || (toolName + '...');
- if (input && typeof input === 'object') {
- if (input.file_path) return `${verb} ${input.file_path.split('/').pop()}`;
- if (input.pattern) return `${verb} "${input.pattern}"`;
- if (input.path) return `${verb} ${input.path.split('/').pop()}`;
- }
- if (input && typeof input === 'string' && input.length < 60) return `${verb} ${input}`;
- return verb;
- }
- function _toolCallSummary(name, input) {
- if (!input) return '';
- const inp = typeof input === 'object' ? input : {};
- switch (name) {
- case 'Bash': return (inp.command || '').substring(0, 100);
- case 'ReadFile': return inp.file_path || '';
- case 'WriteFile': return inp.file_path || '';
- case 'EditFile': return inp.file_path || '';
- case 'Glob': return inp.pattern || '';
- case 'Grep': return `"${inp.pattern || ''}" in ${inp.path || '.'}`;
- case 'VLCompile': case 'VLParse': return 'project';
- case 'VLMetadata': return inp.action || '';
- case 'VLValidate': return inp.file_path || 'all';
- default:
- if (inp.file_path) return inp.file_path;
- if (inp.command) return inp.command.substring(0, 80);
- return '';
- }
- }
- function updateChatElapsed() {
- if (!_chatStartTime) return;
- const sec = Math.round((Date.now() - _chatStartTime) / 1000);
- $('csElapsed').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m${sec%60}s`;
- }
- // ===================== PLAN MODE =====================
- let _planModeActive = false;
- function togglePlanMode() {
- if (_planModeActive) {
- cancelPlan();
- } else {
- enterPlanMode();
- }
- }
- async function enterPlanMode() {
- try {
- await fetch('/api/plan/enter', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
- _planModeActive = true;
- $('planModeBar').style.display = 'flex';
- $('planApproveBtn').style.display = 'none';
- $('planModeToggle').classList.add('active');
- $('chatInput').placeholder = 'Describe what you want to explore/plan...';
- setStatus('Plan Mode (read-only)', 'yellow');
- } catch (e) {
- console.error('enterPlanMode error:', e);
- }
- }
- async function approvePlan() {
- // Send "approve" as a chat message to trigger plan implementation
- $('chatInput').value = 'approve';
- sendMessage();
- }
- async function cancelPlan() {
- try {
- await fetch('/api/plan/cancel', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
- } catch {}
- _planModeActive = false;
- $('planModeBar').style.display = 'none';
- $('planModeToggle').classList.remove('active');
- $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
- setStatus('Ready', 'green');
- }
- function handlePlanModeEvent(data) {
- if (data.phase === 'enter') {
- _planModeActive = true;
- $('planModeBar').style.display = 'flex';
- $('planApproveBtn').style.display = 'none';
- $('planModeToggle').classList.add('active');
- setStatus('Plan Mode (exploring...)', 'yellow');
- } else if (data.phase === 'ready') {
- // Plan is ready for approval
- $('planApproveBtn').style.display = '';
- document.querySelector('.plan-mode-label').innerHTML = '📋 Plan ready — review above';
- setStatus('Plan ready — Approve or Cancel', 'yellow');
- } else if (data.phase === 'exit') {
- _planModeActive = false;
- $('planModeBar').style.display = 'none';
- $('planModeToggle').classList.remove('active');
- $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
- document.querySelector('.plan-mode-label').innerHTML = '🔎 Explore Mode (read-only)';
- }
- }
- async function sendMessage() {
- const input = $('chatInput');
- const msg = input.value.trim();
- if (!msg && !pendingImages.length) return;
- input.value = '';
- autoResizeChatInput(true);
- $('chatSend').disabled = true;
- $('chatSend').style.display = 'none';
- $('chatStop').style.display = '';
- _currentAbortController = new AbortController();
- setChatStatusRunning(true);
- setStatus('Thinking...', 'yellow');
- $('mentionDropdown').classList.remove('open');
- // Add turn separator in Detail Panel
- const detailBody = $('detailBody');
- if (detailBody) {
- const sep = document.createElement('div');
- sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0 4px;font-size:8px;color:var(--text2);padding-top:2px;';
- sep.textContent = '— ' + new Date().toLocaleTimeString() + ' — new turn —';
- detailBody.appendChild(sep);
- }
- clearStreamBoxes();
- // Track turn boundary: record backend message count before this turn
- const turnStartIdx = _lastBackendMsgCount;
- // Show user message with image previews
- const userMsgEl = addMsg('user', msg, pendingImages.map(i => i.preview));
- userMsgEl.dataset.turnStart = turnStartIdx;
- activeToolGroup = null;
- // Build request body
- const body = { message: msg, chatId: activeConvId };
- if (pendingImages.length) {
- body.images = pendingImages.map(i => ({ data: i.data, mediaType: i.mediaType }));
- }
- if (pendingMentions.length) {
- body.mentions = [...pendingMentions];
- }
- // Clear attachments
- pendingImages = [];
- pendingMentions = [];
- $('chatAttachments').innerHTML = '';
- // Auto-attach context screenshots from previous test runs (sent to LLM)
- if (_contextScreenshots.length) {
- body.images = body.images || [];
- for (const ss of _contextScreenshots) {
- try {
- const resp = await fetch(ss.url);
- const blob = await resp.blob();
- const base64 = await blobToBase64(blob);
- body.images.push({ data: base64, mediaType: 'image/png' });
- } catch {}
- }
- _contextScreenshots = [];
- }
- try {
- const res = await fetch('/api/chat', {
- method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify(body),
- signal: _currentAbortController?.signal,
- });
- startSpinnerSafetyTimeout();
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let assistantEl = null;
- let buffer = '';
- let currentEvent = '';
- let _lastSubAgentId = null;
- let _chatCurrentTool = null;
- while (true) {
- const {done, value} = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, {stream:true});
- const lines = buffer.split('\n');
- buffer = lines.pop();
- for (const line of lines) {
- if (line.startsWith('event: ')) {
- currentEvent = line.slice(7);
- continue;
- }
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6));
- debugLog(currentEvent || 'data', data);
- // Thinking indicator
- if (currentEvent === 'thinking') {
- if (data.phase === 'start') {
- addThinkingIndicator();
- updateChatStatusBar('Thinking deeply...', '');
- setStatus('Thinking deeply...', 'yellow');
- addDetailEntry('thinking', 'Extended thinking started...', null, 'info');
- } else if (data.phase === 'delta' && data.text) {
- appendToStreamBox('thinking_stream', 'Thinking', data.text);
- appendThinkingText(data.text);
- } else if (data.phase === 'end') {
- finalizeThinking();
- }
- }
- // Retry indicator
- else if (currentEvent === 'retry') {
- addRetryIndicator(data.attempt, data.delay, data.status);
- const retryMsg = data.status === 'Overloaded'
- ? `API overloaded, retrying (${data.attempt}/3) in ${Math.round(data.delay/1000)}s...`
- : `Retrying (${data.attempt}/3)...`;
- updateChatStatusBar(retryMsg, '');
- setStatus(retryMsg, 'yellow');
- addDetailEntry('retry', retryMsg, null, 'warn');
- }
- // Text token
- else if (data.text) {
- if (!assistantEl) {
- assistantEl = addMsg('assistant', '');
- assistantEl.querySelector('.content-text').dataset.raw = '';
- addDetailEntry('response', 'LLM response streaming...', null, 'info');
- }
- appendToStreamBox('response_stream', 'Response', data.text);
- const textEl = assistantEl.querySelector('.content-text');
- textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
- textEl.textContent += data.text;
- updateChatStatusBar('Responding...', '');
- scrollChat();
- }
- // Tool call - compact indicator
- else if (data.name && data.input !== undefined) {
- _chatCurrentTool = data.name;
- const _linkId = 'tl_' + (++_detailLinkId);
- addToolIndicator(data.name, data.input, 'running', data.detail, _linkId);
- updateChatStatusBar(toolToVerb(data.name, data.input), '');
- setStatus(`${data.name}...`, 'yellow');
- // Detail Panel: full tool info (not sent to LLM — no need to truncate)
- const inputStr = typeof data.input === 'string' ? data.input : JSON.stringify(data.input);
- if (data.name === 'SubAgent') {
- const agentLabel = inputStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
- const agentId = 'agent_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
- getOrCreateAgentGroup(agentId, agentLabel);
- _lastSubAgentId = agentId;
- } else {
- const toolSummary = _toolCallSummary(data.name, data.input);
- // Always pass full input as expandable data — no truncation
- addDetailEntry('tool', `${data.name} ${toolSummary}`, inputStr, 'info', { depth: 0, linkId: _linkId });
- }
- }
- // Tool result - update indicator
- else if (data.name && data.preview !== undefined) {
- updateToolIndicator(data.name, data.preview, data.diff);
- updateChatStatusBar('Thinking...', data.name + ' done');
- // Auto-activate preview when VLParse returns preview URLs
- if (data.name === 'VLParse' && data.preview.includes('Preview URLs')) {
- loadPreviewUrlsFromProfile();
- }
- // Detail Panel: full result — no truncation
- const resultStr = data.detail || (typeof data.preview === 'string' ? data.preview : JSON.stringify(data.preview));
- if (data.name === 'SubAgent' && _lastSubAgentId) {
- completeAgentGroup(_lastSubAgentId, 'done');
- addDetailEntry('result', resultStr, null, 'success', { depth: 1 });
- } else {
- // Use same linkId as the tool call (last assigned)
- const resultLinkId = 'tl_' + _detailLinkId;
- addDetailEntry('result', `${data.name}`, resultStr, 'success', { depth: 0, linkId: resultLinkId });
- }
- }
- // AskUserQuestion widget
- else if (currentEvent === 'ask_user') {
- showAskUserWidget(data);
- updateChatStatusBar('Waiting for your answer...', '');
- setStatus('Waiting for your answer...', 'yellow');
- }
- // Plan Mode events
- else if (currentEvent === 'plan_mode') {
- handlePlanModeEvent(data);
- }
- // Todos
- else if (data.todos) {
- renderTodos(data.todos);
- }
- // Workflow events — show approval UI in chat + load into flow editor
- else if (currentEvent === 'workflow_generated') {
- addWorkflowApproval(data);
- if (data.workflow) {
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
- });
- }
- }
- else if (currentEvent === 'workflow_start') {
- forwardWorkflowEventToIframe('workflow_start', data);
- const wfName = data.name || '';
- _lastWorkflowName = wfName;
- _lastRunCheckpoint = null; // Reset for new run
- // Clear previous step cards
- for (const k in _stepCards) delete _stepCards[k];
- if (wfName.startsWith('autotest')) switchFlowTab('autotest');
- else if (wfName.includes('codegen') || wfName.includes('generate')) switchFlowTab('generate');
- else switchFlowTab('adjust');
- if (wfName) loadWorkflowIntoFlowTab(wfName);
- const wfModel = data.model ? ` [${data.model}]` : '';
- addDetailEntry('workflow', `► Workflow started: ${wfName}${wfModel} (${data.stepCount || '?'} steps)`, null, 'info');
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- assistantEl.querySelector('.content-text').innerHTML += `<div style="font-size:11px;color:var(--blue);padding:4px 0;font-weight:600;">Workflow: ${escapeHtml(wfName)} (${data.stepCount || '?'} steps)${wfModel ? ' — Model: ' + escapeHtml(data.model) : ''}</div>`;
- scrollChat();
- }
- else if (currentEvent === 'node_start') {
- forwardWorkflowEventToIframe('node_start', data);
- updateWfProgressNode(data.nodeId, 'running');
- const nodeLabel = data.title || data.nodeId || '?';
- const nodeType = data.type || '';
- const typeBadge = nodeType ? `[${nodeType}] ` : '';
- // Use enhanced step card in detail log
- addStepCard(data.nodeId, nodeType, nodeLabel, data.resolvedInputs || data.input);
- updateChatStatusBar(`Running ${nodeLabel}...`, '');
- // Show workflow step in chat for visibility
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- const stepLine = document.createElement('div');
- stepLine.className = 'wf-chat-step';
- stepLine.id = `wf-step-${data.nodeId}`;
- stepLine.style.cssText = 'font-size:11px;color:var(--text2);padding:2px 0;';
- stepLine.textContent = `▶ ${typeBadge}${nodeLabel}`;
- assistantEl.querySelector('.content-text').appendChild(stepLine);
- scrollChat();
- }
- else if (currentEvent === 'node_done') {
- forwardWorkflowEventToIframe('node_done', data);
- updateWfProgressNode(data.nodeId, 'done');
- const doneLabel = data.title || data.nodeId || '?';
- const duration = data.duration_ms ? ` (${data.duration_ms >= 1000 ? (data.duration_ms / 1000).toFixed(1) + 's' : data.duration_ms + 'ms'})` : '';
- // Complete step card with outputs
- completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
- // Update chat step line
- const chatStep = document.getElementById(`wf-step-${data.nodeId}`);
- if (chatStep) { chatStep.style.color = 'var(--green)'; chatStep.textContent = `✓ ${doneLabel}${duration}`; }
- }
- else if (currentEvent === 'node_error') {
- forwardWorkflowEventToIframe('node_error', data);
- updateWfProgressNode(data.nodeId, 'error');
- const errLabel = data.title || data.nodeId || '?';
- const errDur = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(1)}s)` : '';
- // Error step card
- errorStepCard(data.nodeId, data.error || data.detail || 'Unknown error', data.duration_ms);
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- const errLine = document.createElement('div');
- errLine.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
- errLine.textContent = '✗ Error in ' + errLabel + errDur + ': ' + (data.error || 'unknown');
- assistantEl.querySelector('.content-text').appendChild(errLine);
- scrollChat();
- }
- else if (currentEvent === 'node_skipped') {
- forwardWorkflowEventToIframe('node_skipped', data);
- addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
- }
- // Workflow pause — show resume/cancel UI in chat
- else if (currentEvent === 'pause') {
- forwardWorkflowEventToIframe('pause', data);
- updateWfProgressNode(data.nodeId, 'paused');
- addPauseResumeUI(data.nodeId, data.title || data.reason || data.nodeId, data.runID || _currentRunID);
- addDetailEntry('workflow', `⏸ Paused: ${data.title || data.nodeId}`, null, 'warn');
- }
- else if (currentEvent === 'resumed') {
- forwardWorkflowEventToIframe('resumed', data);
- updateWfProgressNode(data.nodeId, 'running');
- addDetailEntry('workflow', `▶ Resumed: ${data.nodeId}`, null, 'info');
- }
- // ── Extended LLM communication events (workflow internal) ──
- else if (currentEvent === 'llm_thinking') {
- appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
- }
- else if (currentEvent === 'llm_tool_use') {
- // Show full tool input as expandable JSON
- const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
- addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInputStr, 'info', { depth: 1 });
- updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
- }
- else if (currentEvent === 'llm_tool_result') {
- const isErr = data.is_error || false;
- const rc = data.content || '';
- const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
- // Always show result as expandable data (not just >120 chars)
- addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${data.name || 'Result'}${data.tool_use_id ? ' [' + data.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
- }
- else if (currentEvent === 'tool_start') {
- forwardWorkflowEventToIframe('tool_start', data);
- const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
- addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInputStr, 'info', { depth: 1 });
- updateChatStatusBar(`Tool step: ${data.name || '?'}`, '');
- }
- else if (currentEvent === 'tool_done') {
- forwardWorkflowEventToIframe('tool_done', data);
- const toolOutputStr = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
- addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutputStr, 'success', { depth: 1 });
- }
- else if (currentEvent === 'tool_error') {
- forwardWorkflowEventToIframe('tool_error', data);
- addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
- }
- else if (currentEvent === 'tool_message') {
- forwardWorkflowEventToIframe('tool_message', data);
- const toolDetailStr = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
- addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetailStr, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
- }
- else if (currentEvent === 'llm_done') {
- flushStreamBoxes();
- const mdl = data.model || '';
- const usg = data.usage || {};
- const inTok = usg.input_tokens || usg.prompt_tokens || 0;
- const outTok = usg.output_tokens || usg.completion_tokens || 0;
- const cacheTok = usg.cache_read_input_tokens || 0;
- const lat = data.latency_ms ? `${(data.latency_ms / 1000).toFixed(1)}s` : '';
- const tokenParts = [];
- if (inTok) tokenParts.push(`in:${inTok}`);
- if (cacheTok) tokenParts.push(`cache:${cacheTok}`);
- if (outTok) tokenParts.push(`out:${outTok}`);
- const parts = [mdl, tokenParts.join(' '), lat].filter(Boolean).join(' | ');
- addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
- }
- else if (currentEvent === 'llm_error') {
- const errInfo = [data.error || 'Unknown'];
- if (data.type) errInfo.push(`type:${data.type}`);
- if (data.code) errInfo.push(`code:${data.code}`);
- if (data.latency_ms) errInfo.push(`${(data.latency_ms / 1000).toFixed(1)}s`);
- addDetailEntry('llm', `✗ LLM Error${data.retryable ? ' (retryable)' : ''}: ${errInfo.join(' | ')}`, data, 'error');
- // Show LLM errors in chat too
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- const errDiv = document.createElement('div');
- errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
- errDiv.textContent = `✗ LLM Error: ${data.error || 'Unknown'}`;
- assistantEl.querySelector('.content-text').appendChild(errDiv);
- scrollChat();
- }
- else if (currentEvent === 'var_changed') {
- const vn = data.name || '?';
- const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 120) : '—';
- const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 120) : '—';
- addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
- }
- else if (currentEvent === 'file_start') {
- addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
- }
- else if (currentEvent === 'file_written') {
- const fp = data.path || '?';
- addDetailEntry('file', `✓ Written: ${fp}`, null, 'success', { depth: 1 });
- // Associate file with current running step card
- const runningStep = getCurrentRunningStepID();
- if (runningStep) addFileToStepCard(runningStep, fp);
- // Trigger file tree refresh
- if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
- window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
- _generatedFileCount++;
- }
- else if (currentEvent === 'checkpoint') {
- // Store checkpoint for potential rerun
- _lastRunCheckpoint = data.checkpoint || data;
- addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} steps done)`, null, 'info', { depth: 1 });
- }
- // Screenshots — display inline in chat + add to LLM context
- else if (currentEvent === 'screenshot' && data.screenshots?.length) {
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- for (const ssName of data.screenshots) {
- const url = `/api/browser/screenshot/${ssName}`;
- appendScreenshotToChat(assistantEl, url, ssName);
- _contextScreenshots.push({ url, name: ssName });
- }
- }
- // Done — finalize markdown rendering
- else if (currentEvent === 'done') {
- finalizeAssistantMsg(assistantEl);
- activeToolGroup = null;
- finalizeAllToolSpinners();
- flushStreamBoxes();
- clearSpinnerSafetyTimeout();
- // Track turn end boundary for context exclusion
- if (data.msgCount !== undefined) {
- _lastBackendMsgCount = data.msgCount;
- // Stamp turn boundaries on user message element
- if (userMsgEl) {
- userMsgEl.dataset.turnEnd = data.msgCount - 1;
- }
- }
- // Auto-generate conversation title after first turn
- autoTitleConversation(activeConvId, msg);
- // Auto-compile if VL files were written during this turn
- if (_generatedFileCount > 0) {
- addMsg('assistant', `${_generatedFileCount} VL file(s) written — auto-compiling...`);
- compileProject();
- }
- // Push DOM snapshot to server immediately so other windows can sync
- pushChatStateToServer();
- }
- // Error
- else if (data.message && currentEvent === 'error') {
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
- addDetailEntry('error', data.message, null, 'error');
- }
- } catch {}
- }
- }
- }
- } catch(e) {
- if (e.name === 'AbortError') {
- addMsg('assistant', '⏹ Stopped by user.');
- } else {
- addMsg('assistant', 'Connection error: ' + e.message);
- }
- finalizeAllToolSpinners();
- }
- clearSpinnerSafetyTimeout();
- _currentAbortController = null;
- $('chatStop').style.display = 'none';
- $('chatSend').style.display = '';
- $('chatSend').disabled = false;
- setChatStatusRunning(false);
- setStatus('Ready', 'green');
- updateContext();
- }
- function formatMsgTime(date) {
- const now = new Date();
- const d = date || now;
- const hms = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
- const diffDays = Math.round((today - msgDay) / 86400000);
- let prefix;
- if (diffDays === 0) prefix = 'Today';
- else if (diffDays === 1) prefix = 'Yesterday';
- else prefix = `${d.getMonth() + 1}/${d.getDate()}`;
- return `${prefix} ${hms}`;
- }
- function addMsg(role, text, imagePreviews, timestamp) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'msg ' + role;
- div.style.position = 'relative';
- const content = role === 'assistant' ? renderMarkdown(text) : escapeHtml(text);
- const msgDate = timestamp ? new Date(timestamp) : new Date();
- div.dataset.timestamp = msgDate.toISOString();
- const timeStr = formatMsgTime(msgDate);
- let html = `<div class="label">${role} <span class="msg-time">${timeStr}</span></div><span class="content-text">${content}</span>`;
- // Context toggle button (excludes entire turn from LLM context)
- html += `<button class="msg-ctx-toggle" onclick="toggleMsgContext(this)" title="Toggle: include/exclude this turn from LLM context">ctx</button>`;
- // Show image thumbnails in user messages
- if (imagePreviews?.length) {
- html += '<div class="msg-images">';
- for (const src of imagePreviews) html += `<img src="${src}" onclick="window.open(this.src)">`;
- html += '</div>';
- }
- div.innerHTML = html;
- container.appendChild(div);
- scrollChat();
- return div;
- }
- /** Finalize assistant message: re-render as markdown + add Apply buttons to code blocks */
- function finalizeAssistantMsg(el) {
- if (!el) return;
- const textEl = el.querySelector('.content-text');
- if (textEl) {
- const raw = textEl.dataset.raw || textEl.textContent;
- textEl.innerHTML = renderMarkdown(raw);
- // Add Apply buttons to code blocks
- textEl.querySelectorAll('pre').forEach(pre => {
- const btn = document.createElement('button');
- btn.className = 'code-apply';
- btn.textContent = 'Apply';
- btn.onclick = () => applyCodeBlock(pre);
- pre.style.position = 'relative';
- pre.appendChild(btn);
- });
- // All messages shown in full — no truncation
- }
- }
- /** Apply a code block to the current file */
- async function applyCodeBlock(preEl) {
- if (!currentFile) { setStatus('No file open to apply to', 'red'); return; }
- const code = preEl.querySelector('code')?.textContent || preEl.textContent;
- const editorContent = cmEditor ? cmEditor.getValue() : $('editor').value;
- // Show inline diff
- showInlineDiff(currentFile, editorContent, code, () => {
- // Accept: write to file
- if (cmEditor) cmEditor.setValue(code);
- else $('editor').value = code;
- const info = openFiles.get(currentFile);
- if (info && info.type === 'file') info.content = code;
- else openFiles.set(currentFile, { type: 'file', content: code });
- saveCurrentFile();
- setStatus('Applied to ' + currentFile.split('/').pop(), 'green');
- });
- }
- /** Show inline diff block in chat */
- function showInlineDiff(filePath, oldText, newText, onAccept) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'diff-block';
- const oldLines = oldText.split('\n');
- const newLines = newText.split('\n');
- let diffHtml = '';
- // Simple line-by-line diff
- const maxLen = Math.max(oldLines.length, newLines.length);
- for (let i = 0; i < maxLen; i++) {
- const ol = oldLines[i], nl = newLines[i];
- if (ol === nl) {
- if (ol !== undefined) diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(ol)}</div>`;
- } else {
- if (ol !== undefined) diffHtml += `<div class="diff-line diff-del">${escapeHtml(ol)}</div>`;
- if (nl !== undefined) diffHtml += `<div class="diff-line diff-add">${escapeHtml(nl)}</div>`;
- }
- }
- div.innerHTML = `
- <div class="diff-header">
- <span class="diff-file">${escapeHtml(filePath)}</span>
- <div class="diff-actions">
- <button class="diff-accept" id="diffAccept">Accept</button>
- <button class="diff-reject" id="diffReject">Reject</button>
- </div>
- </div>
- <div class="diff-body">${diffHtml}</div>`;
- container.appendChild(div);
- scrollChat();
- div.querySelector('.diff-accept').onclick = () => { onAccept(); div.remove(); };
- div.querySelector('.diff-reject').onclick = () => { div.remove(); setStatus('Changes rejected', 'yellow'); };
- }
- // Claude Code-style compact tool indicator
- let _generatedFileCount = 0;
- let _subAgentCount = 0;
- // Tool name → icon mapping
- const TOOL_ICONS = {
- Read: '📄', ReadFile: '📄', Glob: '🔍', Grep: '🔎',
- Edit: '✎', EditFile: '✎', Write: '📝', WriteFile: '📝',
- Bash: '▶', VLParse: '⚙', VLValidate: '✓', VLImpact: '⚡',
- VLMetadata: '📊', VLSection: '▦', SubAgent: '⚙', Agent: '⚙',
- };
- function getToolIcon(name) { return TOOL_ICONS[name] || '▶'; }
- function addToolIndicator(name, desc, status, detail, linkId) {
- const container = $('chatMessages');
- const descStr = typeof desc === 'string' ? desc : JSON.stringify(desc);
- // For WriteFile: group all writes into a single summary
- if (name === 'WriteFile') {
- _generatedFileCount++;
- let summary = container.querySelector('.tool-files-summary');
- if (!summary) {
- summary = document.createElement('div');
- summary.className = 'tool-group tool-files-summary';
- summary.innerHTML = `
- <div class="tool-header" onclick="toggleToolBody(this)">
- <div class="tool-spinner"></div>
- <span class="tool-name">WriteFile</span>
- <span class="tool-desc file-count-desc">Writing files... (${_generatedFileCount})</span>
- <span class="tool-toggle">▶</span>
- </div>
- <div class="tool-body file-list-body" style="display:block;padding:4px 8px;"></div>`;
- container.appendChild(summary);
- }
- summary.querySelector('.file-count-desc').textContent = `Writing files... (${_generatedFileCount})`;
- const entry = document.createElement('div');
- entry.style.cssText = 'color:var(--green);font-size:10px;padding:1px 0;';
- entry.textContent = `+ ${descStr}`;
- summary.querySelector('.file-list-body').appendChild(entry);
- activeToolGroup = summary;
- scrollChat();
- return;
- }
- // For SubAgent: group into a progress list showing parallel status
- if (name === 'SubAgent') {
- _subAgentCount++;
- let agentSummary = container.querySelector('.tool-agents-summary');
- if (!agentSummary) {
- agentSummary = document.createElement('div');
- agentSummary.className = 'tool-group tool-agents-summary';
- agentSummary.innerHTML = `
- <div class="tool-header" onclick="toggleToolBody(this)">
- <div class="tool-spinner"></div>
- <span class="tool-name">Parallel Agents</span>
- <span class="tool-desc agent-count-desc">Running ${_subAgentCount} agent(s) in parallel...</span>
- <span class="tool-toggle">▶</span>
- </div>
- <div class="tool-body agent-list-body" style="display:block;padding:4px 8px;"></div>`;
- container.appendChild(agentSummary);
- }
- const runningCount = agentSummary.querySelectorAll('.agent-step:not([data-done])').length + 1;
- agentSummary.querySelector('.agent-count-desc').textContent = `Running ${runningCount} agent(s) in parallel...`;
- const entry = document.createElement('div');
- entry.className = 'agent-step';
- entry.dataset.idx = _subAgentCount;
- entry.dataset.startTime = Date.now();
- // Extract meaningful label from prompt
- const label = descStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
- entry.innerHTML = `<span class="agent-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--yellow);margin-right:6px;animation:pulse 1s ease-in-out infinite;"></span><span style="font-size:10px;">${escapeHtml(label)}...</span><span class="agent-timer" style="margin-left:auto;font-size:9px;color:var(--text2);"></span>`;
- agentSummary.querySelector('.agent-list-body').appendChild(entry);
- activeToolGroup = agentSummary;
- scrollChat();
- // Update timer
- const timerId = setInterval(() => {
- if (entry.dataset.done) { clearInterval(timerId); return; }
- const elapsed = ((Date.now() - parseInt(entry.dataset.startTime)) / 1000).toFixed(0);
- const timer = entry.querySelector('.agent-timer');
- if (timer) timer.textContent = elapsed + 's';
- }, 1000);
- return;
- }
- const group = document.createElement('div');
- group.className = 'tool-group';
- group.dataset.toolName = name;
- group.dataset.startTime = Date.now();
- if (linkId) {
- group.dataset.linkId = linkId;
- // Click tool in chat → open detail panel & highlight corresponding entry
- group.addEventListener('click', () => scrollToDetailEntry(linkId));
- }
- // Build detail body based on structured detail from server
- let detailHtml = '';
- if (detail) {
- if (detail.type === 'edit' && detail.preview?.length) {
- detailHtml = detail.preview.map(e =>
- `<div class="tool-diff"><div class="td-old">- ${escapeHtml(e.old)}</div><div class="td-new">+ ${escapeHtml(e.new)}</div></div>`
- ).join('') + (detail.editCount > 2 ? `<div class="tool-detail"><span class="td-label">total:</span> <span class="td-val">${detail.editCount} edits</span></div>` : '');
- } else if (detail.type === 'grep') {
- detailHtml = `<div class="tool-detail"><span class="td-label">pattern:</span> <span class="td-val">${escapeHtml(detail.pattern)}</span></div>` +
- `<div class="tool-detail"><span class="td-label">path:</span> <span class="td-val">${escapeHtml(detail.path)}</span></div>`;
- } else if (detail.type === 'vlparse') {
- detailHtml = `<div class="tool-detail"><span class="td-label">action:</span> <span class="td-val">${escapeHtml(detail.action || 'parse')}</span></div>` +
- `<div class="tool-detail"><span class="td-label">cookie:</span> <span class="td-val">${detail.cookie}</span></div>`;
- } else if (detail.type === 'write') {
- detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines}</span></div>`;
- } else if (detail.type === 'vledit') {
- detailHtml = `<div class="tool-detail"><span class="td-label">edits:</span> <span class="td-val">${detail.editCount} change(s)</span></div>`;
- } else if (detail.type === 'read') {
- detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines || '?'}</span></div>`;
- } else if (detail.type === 'other' && detail.raw) {
- detailHtml = `<div class="tool-detail" style="opacity:0.6">${escapeHtml(detail.raw)}</div>`;
- }
- }
- const icon = getToolIcon(name);
- group.innerHTML = `
- <div class="tool-header" onclick="toggleToolBody(this)">
- <div class="tool-spinner"></div>
- <span class="tool-icon">${icon}</span>
- <span class="tool-name">${escapeHtml(name)}</span>
- <span class="tool-desc">${escapeHtml(descStr)}</span>
- <span class="tool-time"></span>
- <span class="tool-toggle">▶</span>
- </div>
- <div class="tool-body${detailHtml ? ' open' : ''}">${detailHtml}</div>`;
- // Auto-open toggle if detail present
- if (detailHtml) {
- const toggle = group.querySelector('.tool-toggle');
- if (toggle) toggle.classList.add('open');
- }
- // Start elapsed timer
- const _timerEl = group.querySelector('.tool-time');
- const _timerStart = Date.now();
- group._timerId = setInterval(() => {
- const elapsed = ((Date.now() - _timerStart) / 1000).toFixed(1);
- if (_timerEl) _timerEl.textContent = elapsed + 's';
- }, 200);
- container.appendChild(group);
- activeToolGroup = group;
- scrollChat();
- }
- function updateToolIndicator(name, result, diffData) {
- // For WriteFile: update the summary group's count label
- if (name === 'WriteFile') {
- const summary = document.querySelector('.tool-files-summary');
- if (summary) {
- summary.querySelector('.file-count-desc').textContent = `${_generatedFileCount} files written`;
- }
- return;
- }
- // For SubAgent: mark the latest unfinished step as done
- if (name === 'SubAgent') {
- const agentSummary = document.querySelector('.tool-agents-summary');
- if (agentSummary) {
- const steps = agentSummary.querySelectorAll('.agent-step');
- // Find first step that hasn't been marked done
- for (let i = 0; i < steps.length; i++) {
- if (!steps[i].dataset.done) {
- steps[i].dataset.done = '1';
- const dot = steps[i].querySelector('.agent-dot');
- if (dot) { dot.style.background = 'var(--green)'; dot.style.animation = 'none'; }
- // Show elapsed time
- const startTime = parseInt(steps[i].dataset.startTime || 0);
- if (startTime) {
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
- const timer = steps[i].querySelector('.agent-timer');
- if (timer) timer.textContent = elapsed + 's';
- }
- break;
- }
- }
- // Update header with counts
- const doneCount = agentSummary.querySelectorAll('.agent-step[data-done]').length;
- const totalCount = steps.length;
- const runningCount = totalCount - doneCount;
- if (runningCount > 0) {
- agentSummary.querySelector('.agent-count-desc').textContent = `${runningCount} running, ${doneCount}/${totalCount} done`;
- } else {
- agentSummary.querySelector('.agent-count-desc').textContent = `All ${totalCount} agents done`;
- // Finalize spinner
- const spinner = agentSummary.querySelector('.tool-spinner');
- if (spinner) {
- const parent = spinner.parentNode;
- spinner.remove();
- const icon = document.createElement('div');
- icon.className = 'tool-icon';
- icon.style.color = 'var(--green)';
- icon.textContent = '\u2713';
- parent.prepend(icon);
- }
- }
- }
- return;
- }
- // Find the most recent tool group matching name
- const groups = document.querySelectorAll('.tool-group');
- let target = null;
- for (let i = groups.length - 1; i >= 0; i--) {
- if (groups[i].dataset.toolName === name) {
- target = groups[i]; break;
- }
- }
- if (!target) return;
- // Stop timer
- if (target._timerId) { clearInterval(target._timerId); target._timerId = null; }
- // Replace spinner with check/x icon
- const spinner = target.querySelector('.tool-spinner');
- if (spinner) {
- spinner.remove();
- const doneIcon = document.createElement('span');
- doneIcon.className = 'tool-status-icon done';
- doneIcon.innerHTML = '✓';
- target.querySelector('.tool-header').prepend(doneIcon);
- }
- // Add result badge to header desc
- const desc = target.querySelector('.tool-desc');
- if (desc && result) {
- const shortResult = result.length > 60 ? result.substring(0, 60) + '...' : result;
- if (name === 'ReadFile') {
- desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">${escapeHtml(result)}</span>`;
- } else if (name === 'VLValidate' && (result.includes('valid') || result.includes('pass'))) {
- desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">passed</span>`;
- }
- }
- // Set body content (preserve existing detail if result is short)
- const body = target.querySelector('.tool-body');
- // EditFile: show inline diff + undo button
- if (diffData && diffData.diff) {
- body.innerHTML = '';
- const diffEl = document.createElement('div');
- diffEl.className = 'edit-diff-preview';
- const lines = diffData.diff.split('\n');
- let diffHtml = '';
- for (const line of lines) {
- if (line.startsWith('+')) diffHtml += `<div class="diff-line diff-add">${escapeHtml(line)}</div>`;
- else if (line.startsWith('-')) diffHtml += `<div class="diff-line diff-del">${escapeHtml(line)}</div>`;
- else diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(line)}</div>`;
- }
- diffEl.innerHTML = diffHtml;
- body.appendChild(diffEl);
- // Undo button
- if (diffData.undoId) {
- const undoBtn = document.createElement('button');
- undoBtn.className = 'plan-cancel-btn';
- undoBtn.style.cssText = 'margin-top:4px; font-size:10px; padding:2px 8px;';
- undoBtn.textContent = 'Undo';
- undoBtn.onclick = async () => {
- try {
- const r = await fetch(`/api/undo/${diffData.undoId}`, { method: 'POST' });
- const d = await r.json();
- if (d.ok) { undoBtn.textContent = 'Undone'; undoBtn.disabled = true; setStatus(`Undid edit to ${d.file}`, 'green'); }
- else { setStatus(d.error || 'Undo failed', 'red'); }
- } catch (e) { setStatus('Undo error: ' + e.message, 'red'); }
- };
- body.appendChild(undoBtn);
- }
- body.classList.add('open');
- target.querySelector('.tool-toggle')?.classList.add('open');
- } else if (result && result.length > 30) {
- body.textContent = result;
- } else if (result) {
- const badge = document.createElement('div');
- badge.className = 'tool-detail';
- badge.innerHTML = `<span class="td-val" style="color:var(--green)">${escapeHtml(result)}</span>`;
- body.appendChild(badge);
- }
- }
- /** Finalize ALL tool spinners — called when chat response completes */
- function finalizeAllToolSpinners() {
- // Finalize WriteFile summary
- const wfSummary = document.querySelector('.tool-files-summary');
- if (wfSummary) {
- const desc = wfSummary.querySelector('.file-count-desc');
- if (desc) desc.textContent = `${_generatedFileCount} files written`;
- }
- _generatedFileCount = 0;
- // Finalize SubAgent summary
- const agentSummary = document.querySelector('.tool-agents-summary');
- if (agentSummary) {
- const desc = agentSummary.querySelector('.agent-count-desc');
- if (desc) desc.textContent = `${_subAgentCount} agents completed`;
- // Mark all steps as done
- agentSummary.querySelectorAll('.agent-step:not([data-done])').forEach(step => {
- step.dataset.done = '1';
- const marker = step.querySelector('span');
- if (marker) { marker.textContent = '\u25CF'; marker.style.color = 'var(--green)'; }
- });
- }
- _subAgentCount = 0;
- // Finalize ALL remaining spinning tool groups — stop timers, replace spinners
- document.querySelectorAll('.tool-group').forEach(group => {
- if (group._timerId) { clearInterval(group._timerId); group._timerId = null; }
- });
- document.querySelectorAll('.tool-group .tool-spinner').forEach(spinner => {
- const header = spinner.closest('.tool-header');
- spinner.remove();
- const icon = document.createElement('span');
- icon.className = 'tool-status-icon done';
- icon.innerHTML = '✓';
- header?.prepend(icon);
- });
- }
- // Spinner safety timeout — auto-finalize if stream hangs and fully reset UI
- let _spinnerSafetyTimer = null;
- function startSpinnerSafetyTimeout() {
- clearSpinnerSafetyTimeout();
- _spinnerSafetyTimer = setTimeout(() => {
- console.warn('[VL-Code] Spinner safety timeout (120s) — force-finalizing and resetting UI');
- finalizeAllToolSpinners();
- flushStreamBoxes();
- // Full UI reset so the user is never permanently stuck
- _currentAbortController = null;
- try { $('chatStop').style.display = 'none'; } catch {}
- try { $('chatSend').style.display = ''; $('chatSend').disabled = false; } catch {}
- setChatStatusRunning(false);
- setStatus('Timed out — Ready', 'yellow');
- setTimeout(() => setStatus('Ready', 'green'), 3000);
- _spinnerSafetyTimer = null;
- }, 120000); // 2 minutes
- }
- function clearSpinnerSafetyTimeout() {
- if (_spinnerSafetyTimer) { clearTimeout(_spinnerSafetyTimer); _spinnerSafetyTimer = null; }
- }
- function toggleToolBody(header) {
- const body = header.nextElementSibling;
- const toggle = header.querySelector('.tool-toggle');
- body.classList.toggle('open');
- toggle.classList.toggle('open');
- }
- function renderTodos(todos) {
- // Remove existing
- document.querySelectorAll('.msg.todo-list').forEach(e => e.remove());
- if (!todos || todos.length === 0) return;
- const div = document.createElement('div');
- div.className = 'msg todo-list';
- let html = '<div class="label">Tasks</div>';
- for (const t of todos) {
- const cls = t.status === 'completed' ? 'todo-done' : t.status === 'in_progress' ? 'todo-active' : 'todo-pending';
- const icon = t.status === 'completed' ? '\u2713' : t.status === 'in_progress' ? '\u25B6' : '\u25CB';
- const text = t.status === 'in_progress' ? (t.activeForm || t.content) : t.content;
- // Timing info
- let timing = '';
- if (t.status === 'completed' && t.startedAt && t.completedAt) {
- const secs = ((t.completedAt - t.startedAt) / 1000).toFixed(1);
- timing = `<span class="todo-timing">${secs}s</span>`;
- } else if (t.status === 'in_progress' && t.startedAt) {
- timing = `<span class="todo-timing todo-elapsed" data-start="${t.startedAt}">0s</span>`;
- }
- const spinnerHtml = t.status === 'in_progress' ? '<span class="todo-spinner"></span>' : '';
- html += `<div class="todo-item ${cls}">${spinnerHtml}<span class="todo-icon">${icon}</span><span class="todo-text">${escapeHtml(text)}</span>${timing}</div>`;
- // Render subtasks
- if (t.subtasks?.length) {
- for (const st of t.subtasks) {
- const stCls = st.status === 'completed' ? 'todo-done' : st.status === 'in_progress' ? 'todo-active' : 'todo-pending';
- const stIcon = st.status === 'completed' ? '\u2713' : st.status === 'in_progress' ? '\u25B6' : '\u25CB';
- html += `<div class="todo-item todo-subtask ${stCls}"><span class="todo-icon">${stIcon}</span>${escapeHtml(st.content)}</div>`;
- }
- }
- }
- div.innerHTML = html;
- $('chatMessages').appendChild(div);
- scrollChat();
- // Start elapsed time timers
- div.querySelectorAll('.todo-elapsed').forEach(el => {
- const start = parseInt(el.dataset.start);
- if (!start) return;
- el._tid = setInterval(() => {
- el.textContent = ((Date.now() - start) / 1000).toFixed(0) + 's';
- }, 1000);
- });
- }
- let _chatUserScrolled = false;
- function scrollChat() {
- const el = $('chatMessages');
- // Only auto-scroll if user hasn't manually scrolled up
- if (!_chatUserScrolled) {
- el.scrollTop = el.scrollHeight;
- }
- }
- // Detect manual scroll: if user scrolls up, stop auto-scroll; if near bottom, resume
- (function() {
- let _scrollTimer;
- document.addEventListener('DOMContentLoaded', () => {
- const el = $('chatMessages');
- if (!el) return;
- el.addEventListener('scroll', () => {
- clearTimeout(_scrollTimer);
- _scrollTimer = setTimeout(() => {
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
- _chatUserScrolled = !atBottom;
- }, 50);
- });
- });
- })();
- // chatInput keydown is handled in @-mention section above
- // ===================== GENERATE =====================
- function openGenerate() { $('genModal').classList.add('open'); }
- function closeGenerate() { $('genModal').classList.remove('open'); }
- /** Focus chat input with generate hint (new approach — no modal) */
- function focusGenerate() {
- const input = $('chatInput');
- const wfName = workflowBindings.generate || '3-file-codegen';
- input.value = `Generate a VL project (workflow: ${wfName}): `;
- input.focus();
- input.setSelectionRange(input.value.length, input.value.length);
- }
- async function startGenerate() {
- const req = $('genInput').value.trim();
- if (!req) return;
- $('genStart').disabled = true;
- $('genProgress').style.display = 'block';
- $('genProgress').innerHTML = '';
- setStatus('Generating...', 'yellow');
- try {
- const res = await fetch('/api/generate', {
- method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ userRequest: req, targetLang: 'en' })
- });
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const {done, value} = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, {stream:true});
- const lines = buffer.split('\n');
- buffer = lines.pop();
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6));
- if (data.title) addGenStep('step', data.title);
- if (data.path) addGenStep('file', data.path);
- if (data.filesWritten) { addGenStep('done', `${data.filesWritten.length} files generated`); await loadFileTree(); }
- if (data.message) addGenStep('error', data.message);
- } catch {}
- }
- }
- }
- } catch(e) { addGenStep('error', e.message); }
- $('genStart').disabled = false;
- setStatus('Ready', 'green');
- }
- function addGenStep(type, text) {
- const div = document.createElement('div');
- div.className = 'gen-step';
- const icon = {error:'\u2717', done:'\u2713', file:'\u25A0', step:'\u25B8'}[type] || '\u25B8';
- const color = type === 'error' ? 'var(--red)' : type === 'done' ? 'var(--green)' : 'var(--text2)';
- div.innerHTML = `<span style="color:${color}">${icon}</span> ${escapeHtml(text)}`;
- $('genProgress').appendChild(div);
- }
- /** Run generation via workflow execution with progress visualization in chat */
- async function runGenerateWorkflow() {
- const desc = $('genQuickInput')?.value?.trim();
- if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
- $('genQuickInput')?.closest('.wf-progress')?.remove();
- addMsg('user', desc);
- const wfName = workflowBindings.generate || '3-file-codegen';
- setStatus(`Running workflow: ${wfName}...`, 'yellow');
- addMsg('assistant', `**Running workflow** \`${wfName}\` for: "${desc}"`);
- const progressWidget = addWorkflowProgress(wfName, []);
- try {
- const res = await fetch('/api/workflow/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflowName: wfName, params: { userRequest: desc, description: desc } }),
- });
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const blocks = buffer.split('\n\n');
- buffer = blocks.pop();
- for (const block of blocks) {
- let eType = 'message', eData = null;
- for (const line of block.split('\n')) {
- if (line.startsWith('event: ')) eType = line.slice(7).trim();
- else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
- }
- if (!eData) continue;
- switch (eType) {
- case 'workflow_start':
- if (eData.name) loadWorkflowIntoFlowTab(eData.name);
- addDetailEntry('workflow', `Workflow started: ${eData.name || ''}`, null, 'info');
- break;
- case 'node_start':
- updateWfProgressNode(eData.nodeId, 'running');
- addDetailEntry('workflow', `▶ ${eData.title || eData.nodeId}`, null, 'info');
- break;
- case 'node_done':
- updateWfProgressNode(eData.nodeId, 'done');
- addDetailEntry('workflow', `✓ ${eData.title || eData.nodeId}`, null, 'success');
- break;
- case 'node_error':
- updateWfProgressNode(eData.nodeId, 'error');
- addDetailEntry('workflow', `✗ ${eData.title || eData.nodeId} — ${eData.error || ''}`, null, 'error');
- break;
- case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
- // ── Extended LLM communication events ──
- case 'llm_thinking':
- appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
- break;
- case 'token':
- appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || '');
- break;
- case 'llm_tool_use':
- addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, eData.input || null, 'info', { depth: 1 });
- updateChatStatusBar(`Tool: ${eData.name || '?'}`, '');
- break;
- case 'llm_tool_result': {
- const isErr = eData.is_error || false;
- const resultContent = eData.content || '';
- const resultStr = typeof resultContent === 'string' ? resultContent : JSON.stringify(resultContent);
- addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} Result${eData.tool_use_id ? ' [' + eData.tool_use_id.slice(-8) + ']' : ''}`, resultStr.length > 120 ? resultStr : null, isErr ? 'error' : 'success', { depth: 1 });
- break;
- }
- case 'llm_done': {
- flushStreamBoxes();
- const mdl = eData.model || '';
- const usg = eData.usage || {};
- const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
- const parts = [mdl, usg.input_tokens ? `in:${usg.input_tokens}` : '', usg.output_tokens ? `out:${usg.output_tokens}` : '', lat].filter(Boolean).join(' | ');
- addDetailEntry('llm', `✓ LLM complete — ${parts}`, null, 'success');
- break;
- }
- case 'llm_error':
- addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${eData.error || 'Unknown'}`, eData, 'error');
- break;
- case 'var_changed': {
- const vName = eData.name || '?';
- const vOld = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 80) : '—';
- const vNew = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 80) : '—';
- addDetailEntry('var', `📊 ${vName}: ${vOld} → ${vNew}`, eData, 'info', { depth: 1 });
- break;
- }
- case 'file_start':
- addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
- break;
- case 'pause':
- updateWfProgressNode(eData.nodeId, 'paused');
- addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
- addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
- break;
- case 'resumed':
- updateWfProgressNode(eData.nodeId, 'running');
- addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
- break;
- case 'pause_timeout':
- addDetailEntry('pause', `⏰ Pause timed out → ${eData.timeoutAction || ''}`, eData, 'warn');
- break;
- case 'pause_rejected':
- addDetailEntry('pause', `✗ Resume rejected: ${eData.reason || ''}`, eData, 'error');
- break;
- case 'file_written':
- { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
- break;
- case 'done':
- addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
- addDetailEntry('workflow', 'Workflow completed', null, 'success');
- await loadFileTree();
- await loadProjectInfo();
- setStatus('Generation complete', 'green');
- break;
- case 'error':
- addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
- addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
- setStatus('Workflow error', 'red');
- break;
- }
- }
- }
- } catch (e) {
- addMsg('assistant', '**Workflow failed:** ' + e.message);
- setStatus('Workflow failed', 'red');
- }
- }
- /** Send generate request as normal chat message */
- function sendGenerateAsChat() {
- const desc = $('genQuickInput')?.value?.trim();
- if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
- $('genQuickInput')?.closest('.wf-progress')?.remove();
- const wfName = workflowBindings.generate || '3-file-codegen';
- $('chatInput').value = `Generate a VL project (workflow: ${wfName}): ${desc}`;
- sendMessage();
- }
- // ===================== WORKSPACE STATE =====================
- /** Save full workspace state to .vl-code/workspace.json */
- async function saveWorkspaceState() {
- try {
- const state = {
- savedAt: Date.now(),
- mode: currentMode || 'code',
- activeFile: currentFile || null,
- openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
- debugPanelOpen: $('debugPanel')?.style.display !== 'none',
- chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
- chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
- showInternalFiles,
- wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
- // Chat state is managed by /api/chat/state, not here
- };
- await fetch('/api/workspace/state', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(state),
- });
- } catch {}
- }
- /** Restore workspace state from .vl-code/workspace.json */
- async function restoreWorkspaceState() {
- try {
- const state = await api('/api/workspace/state');
- if (!state || !state.savedAt) return false;
- // Restore conversations
- // Chat state is restored by fetchChatStateFromServer() — NOT from workspace.json
- // Restore open files
- if (state.openFilePaths?.length) {
- for (const fp of state.openFilePaths) {
- try {
- const data = await api(`/api/file?path=${encodeURIComponent(fp)}`);
- const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
- openFiles.set(fp, { type: 'file', content });
- } catch {}
- }
- if (state.activeFile && openFiles.has(state.activeFile)) {
- currentFile = state.activeFile;
- } else if (openFiles.size > 0) {
- currentFile = [...openFiles.keys()].pop();
- }
- renderTabs();
- if (currentFile) showTabContent(currentFile);
- }
- // Restore mode (after files are loaded)
- if (state.mode && state.mode !== 'code') {
- switchMode(state.mode);
- }
- // Restore chatWidth and wfBindings from backend (fill localStorage if missing)
- if (state.chatWidth && !localStorage.getItem('vl-chat-width')) {
- localStorage.setItem('vl-chat-width', String(state.chatWidth));
- applyChatWidth(state.chatWidth);
- }
- if (typeof state.showInternalFiles === 'boolean') {
- setInternalFilesVisible(state.showInternalFiles, { reload: true, persist: true });
- }
- if (state.wfBindings && !localStorage.getItem('vl-code-wf-bindings')) {
- localStorage.setItem('vl-code-wf-bindings', JSON.stringify(state.wfBindings));
- }
- setStatus('Workspace restored', 'green');
- setTimeout(() => setStatus('Ready', 'green'), 2000);
- return true;
- } catch {
- return false;
- }
- }
- // ===================== UTILITIES =====================
- async function api(url, chatId) {
- // Append chatId for session-specific GET endpoints
- const u = chatId !== undefined ? `${url}${url.includes('?') ? '&' : '?'}chatId=${chatId}` : url;
- return (await fetch(u)).json();
- }
- async function updateContext() {
- try {
- const ctx = await api('/api/context', activeConvId);
- const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
- $('ctxLabel').textContent = `${pct}%`;
- $('ctxBar').style.width = pct + '%';
- $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
- // Token detail tooltip
- let detail = `Context: ${(ctx.usedTokens/1000).toFixed(1)}K / ${(ctx.maxTokens/1000).toFixed(0)}K`;
- detail += `\nMessages: ${ctx.messageCount} (${ctx.turnCount} turns)`;
- if (ctx.inputTokens !== undefined) {
- detail += `\nInput: ${(ctx.inputTokens/1000).toFixed(1)}K tokens`;
- detail += `\nOutput: ${(ctx.outputTokens/1000).toFixed(1)}K tokens`;
- if (ctx.cacheRead) detail += `\nCache hit: ${(ctx.cacheRead/1000).toFixed(1)}K`;
- detail += `\nSession: ${(ctx.totalInputTokens/1000).toFixed(1)}K in / ${(ctx.totalOutputTokens/1000).toFixed(1)}K out`;
- // Daily token tracking
- const dailyStats = trackDailyTokens(ctx.totalInputTokens, ctx.totalOutputTokens);
- const dayTotal = ((dailyStats.input + dailyStats.output) / 1000).toFixed(1);
- detail += `\n── Today ──`;
- detail += `\nToday: ${(dailyStats.input/1000).toFixed(1)}K in / ${(dailyStats.output/1000).toFixed(1)}K out (${dayTotal}K total)`;
- }
- $('ctxDetail').textContent = detail;
- } catch {}
- }
- /** Track daily token usage in localStorage */
- let _dailyTokenState = null;
- function trackDailyTokens(sessionIn, sessionOut) {
- const today = new Date().toISOString().slice(0, 10);
- if (!_dailyTokenState) {
- try {
- _dailyTokenState = JSON.parse(localStorage.getItem('vl-daily-tokens') || '{}');
- } catch { _dailyTokenState = {}; }
- }
- if (_dailyTokenState.date !== today) {
- _dailyTokenState = { date: today, input: 0, output: 0, lastSessionIn: sessionIn, lastSessionOut: sessionOut };
- }
- const deltaIn = Math.max(0, sessionIn - (_dailyTokenState.lastSessionIn || 0));
- const deltaOut = Math.max(0, sessionOut - (_dailyTokenState.lastSessionOut || 0));
- _dailyTokenState.input += deltaIn;
- _dailyTokenState.output += deltaOut;
- _dailyTokenState.lastSessionIn = sessionIn;
- _dailyTokenState.lastSessionOut = sessionOut;
- try { localStorage.setItem('vl-daily-tokens', JSON.stringify(_dailyTokenState)); } catch {}
- return { input: _dailyTokenState.input, output: _dailyTokenState.output };
- }
- function setStatus(text, color) {
- $('statusText').textContent = text;
- const dot = document.querySelector('.bottom-bar .dot');
- if (dot) dot.className = 'dot dot-' + color;
- }
- // ─── Tab Activity System ─────────────────────────────────────────────────
- // Tab title management (simplified — no Dragon identity)
- let _tabWorkspaceName = '';
- let _tabStatus = 'idle';
- let _tabFlashTimer = null;
- let _tabFlashState = false;
- let _tabHasFocus = true;
- document.addEventListener('visibilitychange', () => {
- _tabHasFocus = !document.hidden;
- if (_tabHasFocus && _tabStatus === 'newOutput') {
- setTabStatus('idle');
- }
- });
- function setTabStatus(status) {
- _tabStatus = status;
- if (_tabFlashTimer) { clearInterval(_tabFlashTimer); _tabFlashTimer = null; }
- const ws = _tabWorkspaceName;
- const name = ws ? `VLCode Lite — ${ws}` : 'VLCode Lite';
- if (status === 'idle') {
- document.title = name;
- } else if (status === 'busy') {
- document.title = `⚡ ${name} — working...`;
- _tabFlashState = false;
- _tabFlashTimer = setInterval(() => {
- _tabFlashState = !_tabFlashState;
- document.title = _tabFlashState ? `⚡ ${name} — working...` : name;
- }, 1200);
- } else if (status === 'newOutput') {
- document.title = `💬 ${name} ✦ NEW`;
- _tabFlashState = false;
- _tabFlashTimer = setInterval(() => {
- _tabFlashState = !_tabFlashState;
- document.title = _tabFlashState ? `💬 ${name} ✦ NEW` : name;
- }, 1500);
- }
- }
- let _sseSource = null;
- function connectSSE() {
- if (_sseSource) { try { _sseSource.close(); } catch {} }
- const es = new EventSource('/api/events');
- _sseSource = es;
- es.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data);
- if (data.type === 'file_changed') {
- setStatus(`Changed: ${data.path}`, 'yellow');
- setTimeout(() => setStatus('Ready', 'green'), 2000);
- // Debounced file tree refresh — show files appearing in real-time
- if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
- window._fileTreeRefreshTimer = setTimeout(() => {
- loadFileTree();
- window._fileTreeRefreshTimer = null;
- }, 800);
- }
- if (data.type === 'validation_result') {
- setStatus(data.result?.substring(0, 60) || 'Validated', data.result?.includes('Error') ? 'red' : 'green');
- }
- if (data.type === 'file_tree_updated') {
- // File tree data is ready on server — refresh immediately (no debounce needed)
- if (window._fileTreeRefreshTimer) { clearTimeout(window._fileTreeRefreshTimer); window._fileTreeRefreshTimer = null; }
- loadFileTree();
- }
- if (data.type === 'project_reloaded') {
- loadFileTree();
- loadProjectInfo();
- }
- if (data.type === 'conversations_cleared') {
- // Server cleared all sessions — reset frontend to match
- localStorage.removeItem(chatStorageKey());
- resetConversationState();
- saveChatState();
- }
- if (data.type === 'workspace_switched') {
- $('wsPopover')?.classList.remove('open');
- // Update currentWorkDir immediately so loadWorkspaces() marks the right entry as active
- currentWorkDir = data.workDir || '';
- loadFileTree();
- loadProjectInfo();
- previewUrls = {};
- $('previewUrlsPanel').style.display = 'none';
- $('previewUrlsList').innerHTML = '';
- $('previewUrlLabel').textContent = '';
- if ($('cloudGid')) $('cloudGid').value = '';
- if (currentWorkDir) {
- Promise.all([
- loadPreviewUrlsFromProfile(),
- loadCloudGid(),
- ]).catch(() => {});
- }
- renderWsTabs(); // Refresh tabs directly in case loadWorkspaces fails
- loadWorkspaces();
- // Reload chat state (conversations/tabs) from new workspace
- if (currentWorkDir) fetchChatStateFromServer();
- else resetConversationState();
- // Refresh Map if currently visible — metadata from old workspace is stale
- if (currentMode === 'meta') {
- switchMode('meta'); // re-triggers metadata load from new workspace
- }
- }
- if (data.type === 'settings_changed') {
- loadProjectInfo();
- }
- if (data.type === 'cloud_status') {
- if (data.status === 'connected') showCloudConnected(data.user);
- else showCloudDisconnected();
- }
- if (data.type === 'compile_done') {
- if (data.gid && $('cloudGid')) $('cloudGid').value = String(data.gid);
- if (data.previewUrls && Object.keys(data.previewUrls).length > 0) {
- activatePreview(data.previewUrls);
- } else if (currentWorkDir) {
- loadPreviewUrlsFromProfile();
- }
- }
- if (data.type === 'cloud_sync') {
- if (data.status === 'pushing' || data.status === 'pulling') {
- showCloudSyncStatus(data.status === 'pushing' ? 'Pushing...' : 'Pulling...', 'syncing');
- } else if (data.status === 'pushed') {
- showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
- } else if (data.status === 'pulled') {
- showCloudSyncStatus(`Pulled ${data.fileCount} files`, 'ok');
- } else if (data.status === 'error') {
- showCloudSyncStatus('Error: ' + data.message, 'error');
- }
- }
- if (data.type === 'server_restart') {
- setStatus('Server restarting...', 'yellow');
- // Wait for server to come back, then reload
- setTimeout(() => waitForServerAndReload(), data.delay || 2000);
- }
- // Workflow trigger (local — approveAndRunWorkflow)
- if (data.type === 'run_workflow') {
- const wfName = data.payload?.workflowName;
- if (wfName) {
- addMsg('assistant', `**Workflow trigger:** ${wfName}`);
- const fakeBtn = document.createElement('button');
- const fakeDiv = document.createElement('div');
- fakeDiv.className = 'wf-progress-actions';
- fakeDiv.appendChild(fakeBtn);
- const wrapper = document.createElement('div');
- wrapper.className = 'wf-progress';
- wrapper.appendChild(fakeDiv);
- $('chatMessages').appendChild(wrapper);
- approveAndRunWorkflow(wfName, fakeBtn);
- }
- }
- // ── Multi-window sync events ──
- if (data.type === 'ws_tabs_changed') {
- renderWsTabs(); // refresh from /api/windows
- }
- // Codegen workflow selector changed in another window
- if (data.type === 'wf_selection_changed' && data.workflow) {
- if (data.workflow !== _selectedCodegenWorkflow) {
- _selectedCodegenWorkflow = data.workflow;
- if (CODEGEN_WORKFLOWS[data.workflow]) {
- $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.workflow].label;
- workflowBindings.generate = CODEGEN_WORKFLOWS[data.workflow].file;
- }
- }
- }
- // Flow tab workflow dropdown changed in another window
- if (data.type === 'ui_state_changed' || data.type === 'ui_state') {
- if (data.flowWorkflow) {
- _setFlowWfSelectOrStore(data.flowWorkflow, $('flowWfSelect'));
- }
- }
- // Workflow run state — sent on SSE connect for windows opening mid-run
- if (data.type === 'current_run_state' && data.active && data.workflowName) {
- // Restore running workflow in Flow tab (already handled by wf_start broadcasts during run)
- _workflowActive = true;
- _lastWorkflowName = data.workflowName;
- window._skipFlowAutoLoad = true;
- loadWorkflowIntoFlowTab(data.workflowName);
- }
- // Chat state changed in another window — sync messages
- if (data.type === 'chat_state_changed') {
- const convId = data.chatId;
- const conv = conversations.find(c => c.id === convId);
- if (conv && data.messageCount > 0 && !_currentAbortController) {
- const domCount = ($('chatMessages')?.querySelectorAll('.msg').length) || 0;
- if (convId === activeConvId && data.messageCount > domCount) {
- _rebuildChatDom(convId);
- } else if (convId !== activeConvId) {
- conv.dom = '';
- conv.messageCount = data.messageCount;
- }
- }
- }
- } catch {}
- };
- // Auto-reconnect on disconnect
- es.onerror = () => {
- es.close();
- _sseSource = null;
- setStatus('Disconnected — reconnecting...', 'red');
- setTimeout(() => {
- fetch('/api/version').then(r => r.json()).then(() => {
- setStatus('Reconnected', 'green');
- connectSSE();
- loadProjectInfo();
- loadFileTree();
- }).catch(() => setTimeout(connectSSE, 3000));
- }, 2000);
- };
- }
- // ═══════ Server Health Detection (P2) ═══════
- let _healthFails = 0;
- let _disconnectOverlay = null;
- setInterval(async () => {
- try {
- const r = await fetch('/api/health', { signal: AbortSignal.timeout(3000) });
- if (r.ok) {
- _healthFails = 0;
- if (_disconnectOverlay) { _disconnectOverlay.remove(); _disconnectOverlay = null; }
- } else _healthFails++;
- } catch { _healthFails++; }
- if (_healthFails >= 3 && !_disconnectOverlay) {
- _disconnectOverlay = document.createElement('div');
- _disconnectOverlay.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#f38ba8;color:#1e1e2e;text-align:center;padding:6px;font-weight:600;font-size:13px;';
- _disconnectOverlay.textContent = '⚠ Server disconnected — reconnecting...';
- document.body.appendChild(_disconnectOverlay);
- }
- }, 10000);
- /** Wait for server to come back after restart, then soft-reload */
- function waitForServerAndReload() {
- let attempts = 0;
- const check = () => {
- fetch('/api/version').then(r => r.json()).then(data => {
- setStatus(`Server v${data.version} ready`, 'green');
- // Soft reload: reconnect SSE, reload data, keep UI state
- connectSSE();
- loadProjectInfo();
- loadFileTree();
- loadWorkspaces();
- updateContext();
- }).catch(() => {
- if (++attempts < 20) setTimeout(check, 1000);
- else setStatus('Server not responding — please reload page', 'red');
- });
- };
- setTimeout(check, 1000);
- }
- /** Show the update button in header */
- function showUpdateButton() {
- const btn = $('updateBtn');
- if (btn) btn.style.display = '';
- }
- function escapeHtml(s) {
- if (!s) return '';
- return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
- }
- /** Simple markdown → HTML renderer */
- function renderMarkdown(text) {
- if (!text) return '';
- let html = escapeHtml(text);
- // Code blocks (``` ... ```)
- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
- `<pre><code class="lang-${lang}">${code.trim()}</code></pre>`);
- // Inline code
- html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
- // Headers
- html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
- html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
- html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
- // Bold + italic
- html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
- // Blockquote
- html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
- // Unordered list
- html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
- html = html.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
- // Ordered list
- html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
- // Links (markdown format)
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
- // Auto-link bare URLs (not already inside href or tag)
- html = html.replace(/(^|[^"=>])(https?:\/\/[^\s<)"']+)/g, '$1<a href="$2" target="_blank">$2</a>');
- // Paragraphs (double newline)
- html = html.replace(/\n\n/g, '</p><p>');
- html = `<p>${html}</p>`;
- html = html.replace(/<p><(h[123]|pre|ul|ol|blockquote)/g, '<$1');
- html = html.replace(/<\/(h[123]|pre|ul|ol|blockquote)><\/p>/g, '</$1>');
- // Single newlines → <br> (but not inside pre)
- html = html.replace(/<p>([\s\S]*?)<\/p>/g, (_, inner) =>
- `<p>${inner.replace(/\n/g, '<br>')}</p>`);
- return html;
- }
- // ===================== IMAGE UPLOAD =====================
- function setupImagePaste() {
- // Paste images from clipboard
- document.addEventListener('paste', e => {
- const items = e.clipboardData?.items;
- if (!items) return;
- for (const item of items) {
- if (item.type.startsWith('image/')) {
- e.preventDefault();
- const file = item.getAsFile();
- addImageAttachment(file);
- }
- }
- });
- // File input change
- $('imageInput').addEventListener('change', e => {
- for (const file of e.target.files) addImageAttachment(file);
- e.target.value = '';
- });
- // Drag image onto chat input area
- const inputArea = document.querySelector('.chat-input-area');
- inputArea.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
- inputArea.addEventListener('drop', e => {
- e.preventDefault();
- for (const file of e.dataTransfer.files) {
- if (file.type.startsWith('image/')) addImageAttachment(file);
- }
- });
- }
- function addImageAttachment(file) {
- const reader = new FileReader();
- reader.onload = () => {
- const dataUrl = reader.result;
- const base64 = dataUrl.split(',')[1];
- const mediaType = file.type;
- pendingImages.push({ data: base64, mediaType, preview: dataUrl });
- renderAttachments();
- };
- reader.readAsDataURL(file);
- }
- function renderAttachments() {
- const container = $('chatAttachments');
- container.innerHTML = '';
- for (let i = 0; i < pendingImages.length; i++) {
- const div = document.createElement('div');
- div.className = 'chat-attach-item';
- div.innerHTML = `<img src="${pendingImages[i].preview}"><span class="remove" onclick="removeImage(${i})">×</span>`;
- container.appendChild(div);
- }
- for (let i = 0; i < pendingMentions.length; i++) {
- const div = document.createElement('div');
- div.className = 'chat-attach-item';
- div.innerHTML = `@${escapeHtml(pendingMentions[i])}<span class="remove" onclick="removeMention(${i})">×</span>`;
- container.appendChild(div);
- }
- }
- function removeImage(idx) { pendingImages.splice(idx, 1); renderAttachments(); }
- function removeMention(idx) { pendingMentions.splice(idx, 1); renderAttachments(); }
- function autoResizeChatInput(reset = false) {
- const input = $('chatInput');
- if (!input) return;
- if (reset) input.style.height = '';
- input.style.height = 'auto';
- input.style.height = Math.min(input.scrollHeight, 180) + 'px';
- }
- // ===================== @-MENTION AUTOCOMPLETE =====================
- $('chatInput').addEventListener('input', async function(e) {
- autoResizeChatInput();
- const val = this.value;
- const atIdx = val.lastIndexOf('@');
- if (atIdx === -1 || atIdx < val.lastIndexOf(' ', this.selectionStart - 1)) {
- $('mentionDropdown').classList.remove('open');
- return;
- }
- const query = val.slice(atIdx + 1, this.selectionStart).toLowerCase();
- if (query.length === 0 && val[atIdx - 1] && val[atIdx - 1] !== ' ') {
- $('mentionDropdown').classList.remove('open');
- return;
- }
- // Fetch matching files
- try {
- const files = await api(`/api/files/autocomplete?q=${encodeURIComponent(query)}`);
- if (files.length === 0) { $('mentionDropdown').classList.remove('open'); return; }
- const dd = $('mentionDropdown');
- dd.innerHTML = '';
- mentionIdx = -1;
- for (const f of files) {
- const item = document.createElement('div');
- item.className = 'mention-item';
- const typeClass = 'type-' + getType(f.name);
- item.innerHTML = `<span class="m-type ${typeClass}">${f.type || getType(f.name)}</span>${escapeHtml(f.name)}`;
- item.onclick = () => selectMention(f.name, atIdx);
- dd.appendChild(item);
- }
- dd.classList.add('open');
- } catch {}
- });
- $('chatInput').addEventListener('keydown', function(e) {
- const dd = $('mentionDropdown');
- if (!dd.classList.contains('open')) {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
- return;
- }
- const items = dd.querySelectorAll('.mention-item');
- if (e.key === 'ArrowDown') { e.preventDefault(); mentionIdx = Math.min(mentionIdx + 1, items.length - 1); highlightMention(items); }
- else if (e.key === 'ArrowUp') { e.preventDefault(); mentionIdx = Math.max(mentionIdx - 1, 0); highlightMention(items); }
- else if (e.key === 'Enter' || e.key === 'Tab') {
- e.preventDefault();
- if (mentionIdx >= 0 && items[mentionIdx]) {
- const name = items[mentionIdx].textContent.trim();
- const atIdx = this.value.lastIndexOf('@');
- selectMention(name, atIdx);
- }
- } else if (e.key === 'Escape') {
- dd.classList.remove('open');
- }
- });
- function highlightMention(items) {
- items.forEach((el, i) => el.classList.toggle('selected', i === mentionIdx));
- }
- function selectMention(name, atIdx) {
- const input = $('chatInput');
- input.value = input.value.slice(0, atIdx) + '@' + name + ' ' + input.value.slice(input.selectionStart);
- input.focus();
- $('mentionDropdown').classList.remove('open');
- if (!pendingMentions.includes(name)) {
- pendingMentions.push(name);
- renderAttachments();
- }
- }
- // ===================== CONVERSATION TABS =====================
- function renderConvTabs() {
- const tabs = $('convTabs');
- // Preserve history panel if open
- const existingPanel = tabs.querySelector('.history-panel');
- const panelWasOpen = existingPanel?.classList.contains('open');
- tabs.innerHTML = '';
- for (const conv of conversations) {
- const tab = document.createElement('div');
- tab.className = 'conv-tab' + (conv.id === activeConvId ? ' active' : '');
- tab.dataset.conv = conv.id;
- tab.innerHTML = `${escapeHtml(conv.name)}${conversations.length > 1 ? '<span class="conv-close" onclick="event.stopPropagation();closeConversation(' + conv.id + ')">×</span>' : ''}`;
- tab.onclick = () => switchConversation(conv.id);
- tabs.appendChild(tab);
- }
- const addBtn = document.createElement('button');
- addBtn.className = 'conv-new';
- addBtn.textContent = '+';
- addBtn.onclick = newConversation;
- addBtn.title = 'New conversation';
- tabs.appendChild(addBtn);
- // Spacer pushes history button to the right
- const spacer = document.createElement('div');
- spacer.className = 'tab-spacer';
- tabs.appendChild(spacer);
- // History button (right-aligned)
- const histBtn = document.createElement('button');
- histBtn.className = 'conv-history-btn';
- histBtn.innerHTML = 'History';
- histBtn.title = 'Chat history';
- histBtn.onclick = (e) => { e.stopPropagation(); toggleHistoryPanel(); };
- tabs.appendChild(histBtn);
- // History dropdown panel
- const panel = document.createElement('div');
- panel.className = 'history-panel';
- panel.id = 'historyPanel';
- panel.innerHTML = `<div class="history-search"><input id="historySearchInput" placeholder="Search history..." oninput="searchHistory(this.value)"></div><div class="history-list" id="historyList"><div class="history-empty">Loading...</div></div>`;
- panel.onclick = (e) => e.stopPropagation();
- tabs.appendChild(panel);
- if (panelWasOpen) { panel.classList.add('open'); loadHistoryItems(); }
- }
- async function newConversation() {
- // Save current chat DOM
- const curConv = conversations.find(c => c.id === activeConvId);
- if (curConv) curConv.dom = $('chatMessages').innerHTML;
- // Create on backend first (source of truth)
- try {
- const res = await fetch('/api/conversations', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
- });
- const data = await res.json();
- const id = data.id;
- const name = data.name;
- conversations.push({ id, name, messages: [], dom: '' });
- if (id >= convIdCounter) convIdCounter = id + 1;
- activeConvId = id;
- } catch {
- // Fallback: local creation
- const id = convIdCounter++;
- conversations.push({ id, name: `Chat ${id + 1}`, messages: [], dom: '' });
- activeConvId = id;
- }
- $('chatMessages').innerHTML = '';
- renderConvTabs();
- saveChatState();
- }
- async function switchConversation(id) {
- if (id === activeConvId) return;
- const curConv = conversations.find(c => c.id === activeConvId);
- if (curConv) curConv.dom = $('chatMessages').innerHTML;
- activeConvId = id;
- const target = conversations.find(c => c.id === id);
- if (target?.dom) {
- // Check if DOM is stale vs server message count
- const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
- if (domMsgCount < (target.messageCount || 0)) {
- $('chatMessages').innerHTML = '';
- await _rebuildChatDom(id);
- } else {
- $('chatMessages').innerHTML = target.dom;
- }
- } else {
- $('chatMessages').innerHTML = '';
- // Try to rebuild from server messages when dom is empty
- await _rebuildChatDom(id);
- }
- renderConvTabs();
- // Push active tab change to backend
- pushChatStateToServer();
- }
- async function closeConversation(id) {
- if (conversations.length <= 1) return;
- const conv = conversations.find(c => c.id === id);
- // If conversation has content, AI summarize & archive
- if (conv) {
- const dom = (id === activeConvId) ? ($('chatMessages')?.innerHTML || '') : (conv.dom || '');
- if (dom && dom.length > 50) {
- const tmp = document.createElement('div');
- tmp.innerHTML = dom;
- const msgs = [];
- tmp.querySelectorAll('.msg').forEach(el => {
- const role = el.classList.contains('user') ? 'user' : 'assistant';
- const text = el.querySelector('.content-text')?.textContent || '';
- const time = el.dataset.timestamp || '';
- if (text.trim()) msgs.push({ role, text: text.substring(0, 1000), time });
- });
- if (msgs.length > 0) {
- fetch('/api/chat/summarize-and-save', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ convId: id, name: conv.name, messages: msgs }),
- }).catch(() => {});
- }
- }
- }
- // Delete on backend first
- try { await fetch(`/api/conversations/${id}`, { method: 'DELETE' }); } catch {}
- // Update local state
- conversations = conversations.filter(c => c.id !== id);
- if (activeConvId === id) {
- activeConvId = conversations[0].id;
- const target = conversations[0];
- $('chatMessages').innerHTML = target?.dom || '';
- }
- renderConvTabs();
- saveChatState();
- // Immediately sync to backend (don't wait for 10s interval)
- pushChatStateToServer();
- }
- /** Auto-generate a short title for a conversation after its first chat turn */
- async function autoTitleConversation(convId, userMessage) {
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- // Only auto-title if name still matches default pattern (Chat N)
- if (!/^Chat \d+$/.test(conv.name)) return;
- try {
- const res = await fetch('/api/chat/generate-title', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ chatId: convId, userMessage }),
- });
- const data = await res.json();
- if (data.ok && data.title) {
- conv.name = data.title;
- renderConvTabs();
- saveChatState();
- // Sync to backend registry
- fetch(`/api/conversations/${convId}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: data.title }),
- }).catch(() => {});
- }
- } catch {}
- }
- /** Save current conversation to server (persistent across browser refreshes) */
- async function saveConversationToServer(title) {
- try {
- const convTitle = title || `Chat ${activeConvId + 1} — ${new Date().toLocaleString()}`;
- const res = await fetch('/api/conversation/save', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: `conv_${activeConvId}`, title: convTitle, chatId: activeConvId }),
- });
- const data = await res.json();
- if (data.ok) setStatus(`Saved: ${convTitle}`, 'green');
- else setStatus(data.error || 'Save failed', 'red');
- } catch (e) { setStatus('Save error: ' + e.message, 'red'); }
- }
- /** Restore a conversation from server */
- async function restoreConversationFromServer(convId) {
- try {
- const res = await fetch(`/api/conversation/restore/${convId}`, { method: 'POST' });
- const data = await res.json();
- if (data.ok) {
- $('chatMessages').innerHTML = '';
- addMsg('assistant', `Restored conversation: ${data.conversation.title} (${data.conversation.messageCount} messages in context)`);
- setStatus(`Restored: ${data.conversation.title}`, 'green');
- updateContext();
- } else {
- setStatus(data.error || 'Restore failed', 'red');
- }
- } catch (e) { setStatus('Restore error: ' + e.message, 'red'); }
- }
- // ── History Panel ──
- let _historyCache = null;
- let _historyQuery = '';
- function toggleHistoryPanel() {
- const panel = $('historyPanel');
- if (!panel) return;
- const isOpen = panel.classList.contains('open');
- if (isOpen) {
- panel.classList.remove('open');
- } else {
- panel.classList.add('open');
- loadHistoryItems();
- // Close on outside click
- setTimeout(() => {
- const closer = (e) => {
- if (!panel.contains(e.target) && !e.target.classList.contains('conv-history-btn')) {
- panel.classList.remove('open');
- document.removeEventListener('click', closer);
- }
- };
- document.addEventListener('click', closer);
- }, 0);
- }
- }
- async function loadHistoryItems(query) {
- const list = $('historyList');
- if (!list) return;
- try {
- const q = query !== undefined ? query : _historyQuery;
- const res = await fetch('/api/chat/history' + (q ? '?q=' + encodeURIComponent(q) : ''));
- const data = await res.json();
- _historyCache = data.items || [];
- renderHistoryList(_historyCache);
- } catch (e) {
- list.innerHTML = '<div class="history-empty">Failed to load history</div>';
- }
- }
- function searchHistory(query) {
- _historyQuery = query;
- if (_historyCache && !query) {
- renderHistoryList(_historyCache);
- return;
- }
- // Debounce: load from server with query
- clearTimeout(searchHistory._timer);
- searchHistory._timer = setTimeout(() => loadHistoryItems(query), 200);
- }
- const CATEGORY_COLORS = {
- bug_fix: '#f85149', feature: '#3fb950', refactor: '#a371f7',
- question: '#58a6ff', config: '#d29922', design: '#f778ba', general: '#8b949e', other: '#8b949e',
- };
- function renderHistoryList(items) {
- const list = $('historyList');
- if (!list) return;
- if (!items || items.length === 0) {
- list.innerHTML = '<div class="history-empty">No history found</div>';
- return;
- }
- list.innerHTML = '';
- for (const item of items) {
- const div = document.createElement('div');
- div.className = 'history-item';
- const date = item.archivedAt ? new Date(item.archivedAt) : null;
- const dateStr = date ? formatRelativeDate(date) : '';
- const tagColor = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.general;
- div.innerHTML = `<div class="hi-title">${escapeHtml(item.name)}</div>`
- + `<div class="hi-meta">`
- + `<span class="hi-tag" style="color:${tagColor};border-color:${tagColor}40">${item.category}</span>`
- + `<span>${item.messageCount} msgs</span>`
- + `<span>${dateStr}</span>`
- + `</div>`
- + (item.summary ? `<div class="hi-summary">${escapeHtml(item.summary)}</div>` : '');
- div.onclick = () => restoreHistoryItem(item);
- list.appendChild(div);
- }
- }
- function formatRelativeDate(d) {
- const now = new Date();
- const diff = now - d;
- const mins = Math.floor(diff / 60000);
- if (mins < 1) return 'just now';
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d ago`;
- return d.toLocaleDateString();
- }
- async function restoreHistoryItem(item) {
- // Close the panel
- $('historyPanel')?.classList.remove('open');
- // Show summary in chat
- addMsg('assistant', `**Restored from history:** ${item.name}\n\n` +
- (item.summary ? `> ${item.summary}\n\n` : '') +
- (item.userNeeds ? `**User needs:** ${item.userNeeds}\n` : '') +
- `**Category:** ${item.category} | **Messages:** ${item.messageCount}`);
- scrollChat();
- setStatus(`Loaded: ${item.name}`, 'green');
- }
- // ===================== THINKING INDICATOR =====================
- let activeThinkingEl = null;
- function addThinkingIndicator() {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'thinking-block';
- div.innerHTML = `
- <div class="thinking-header" onclick="this.nextElementSibling.classList.toggle('open')">
- <span class="think-icon">💭</span> <span>Thinking...</span>
- </div>
- <div class="thinking-body"></div>`;
- container.appendChild(div);
- activeThinkingEl = div;
- scrollChat();
- }
- function appendThinkingText(text) {
- if (!activeThinkingEl) return;
- activeThinkingEl.querySelector('.thinking-body').textContent += text;
- }
- function finalizeThinking() {
- if (!activeThinkingEl) return;
- activeThinkingEl.classList.add('done');
- const header = activeThinkingEl.querySelector('.thinking-header span:last-child');
- const body = activeThinkingEl.querySelector('.thinking-body');
- const chars = body.textContent.length;
- header.textContent = `Thought for ${chars > 500 ? Math.round(chars / 100) + ' blocks' : chars + ' chars'}`;
- activeThinkingEl = null;
- }
- function addRetryIndicator(attempt, delay, status) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'retry-msg';
- div.innerHTML = `⚡ Retry ${attempt}/3 (${status}) — waiting ${(delay / 1000).toFixed(1)}s`;
- container.appendChild(div);
- scrollChat();
- }
- // ===================== KEYBOARD SHORTCUTS =====================
- document.addEventListener('keydown', e => {
- // Escape: close any open modal / context menu
- if (e.key === 'Escape') {
- $('settingsModal').classList.remove('open');
- $('genModal').classList.remove('open');
- $('fileCtxMenu').classList.remove('open');
- closeChatMoreMenu();
- }
- // Cmd/Ctrl+K: clear chat
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
- e.preventDefault();
- $('chatMessages').innerHTML = '';
- }
- // Cmd/Ctrl+/: focus chat input
- if ((e.metaKey || e.ctrlKey) && e.key === '/') {
- e.preventDefault();
- $('chatInput').focus();
- }
- });
- // ===================== LOAD FOLDER =====================
- async function loadFolder() {
- if (window.showDirectoryPicker) {
- try {
- const dirHandle = await window.showDirectoryPicker();
- setStatus('Reading folder...', 'yellow');
- const files = await readDirHandle(dirHandle, '');
- await uploadFiles(files);
- return;
- } catch (e) { if (e.name === 'AbortError') return; }
- }
- $('folderInput').click();
- }
- $('folderInput').addEventListener('change', async (e) => {
- if (!e.target.files?.length) return;
- setStatus('Reading folder...', 'yellow');
- await uploadFiles(await readFileList(e.target.files));
- e.target.value = '';
- });
- async function readDirHandle(dirHandle, prefix) {
- const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
- const files = [];
- for await (const entry of dirHandle.values()) {
- const ep = prefix ? `${prefix}/${entry.name}` : entry.name;
- if (entry.kind === 'directory') {
- if (entry.name === 'node_modules' || entry.name === '.git') continue; // skip heavy dirs
- files.push(...await readDirHandle(entry, ep));
- } else if (entry.kind === 'file') {
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop().toLowerCase() : '';
- if (!ext || codeExts.includes(ext)) {
- files.push({ path: ep, content: await (await entry.getFile()).text() });
- }
- }
- }
- return files;
- }
- async function readFileList(fileList) {
- const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
- const files = [];
- for (const file of fileList) {
- const ext = file.name.includes('.') ? '.' + file.name.split('.').pop().toLowerCase() : '';
- if (ext && !codeExts.includes(ext)) continue;
- const parts = (file.webkitRelativePath || file.name).split('/');
- files.push({ path: parts.length > 1 ? parts.slice(1).join('/') : parts[0], content: await file.text() });
- }
- return files;
- }
- async function uploadFiles(files) {
- if (!files?.length) { setStatus('No files found to import', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
- setStatus(`Uploading ${files.length} files...`, 'yellow');
- try {
- await ensureWorkspaceForImport('ImportedFolder');
- const res = await fetch('/api/upload-folder', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({files}) });
- const data = await res.json();
- if (data.error) { setStatus(data.error, 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
- setStatus(`Imported ${data.filesWritten} files`, 'green');
- await loadFileTree();
- await loadProjectInfo();
- autoOpenFirstFile();
- } catch (e) { setStatus('Upload failed', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); }
- }
- /** Auto-open the first VL file (.vx preferred) after loading a project */
- async function autoOpenFirstFile() {
- try {
- const data = await api('/api/files');
- const priority = ['vx','sc','cp','vs','vdb','vth'];
- for (const ext of priority) {
- const found = data.files.find(f => f.endsWith('.' + ext));
- if (found) { openFile(found); return; }
- }
- if (data.files.length > 0) openFile(data.files[0]);
- } catch {}
- }
- // ===================== FILE MANAGEMENT (Delete / Clear / ZIP) =====================
- async function deleteFile(fpath) {
- if (!fpath) return;
- try {
- await fetch(`/api/file?path=${encodeURIComponent(fpath)}`, { method: 'DELETE' });
- // Close tab if open
- if (openFiles.has(fpath)) closeTab(fpath);
- await loadFileTree();
- setStatus('Deleted ' + fpath.split('/').pop(), 'green');
- } catch (e) { setStatus('Delete failed', 'red'); }
- }
- async function clearAllFiles() {
- if (!confirm('Delete ALL VL files in this project? This cannot be undone.')) return;
- try {
- await fetch('/api/files/clear', { method: 'POST' });
- openFiles.clear();
- currentFile = null;
- renderTabs();
- $('editor').style.display = 'none';
- $('iframeContainer').style.display = 'none';
- $('editorPlaceholder').style.display = 'block';
- await loadFileTree();
- await loadProjectInfo();
- setStatus('All files cleared', 'green');
- } catch (e) { setStatus('Clear failed', 'red'); }
- }
- /** Initialize VL project structure with directories and core files */
- async function initProject() {
- setStatus('Initializing project...', 'yellow');
- try {
- await fetch('/api/project/init', { method: 'POST' });
- await loadFileTree();
- await loadProjectInfo();
- setStatus('Project initialized', 'green');
- } catch (e) { setStatus('Init failed', 'red'); }
- }
- function importZip() { $('zipInput').click(); }
- $('zipInput').addEventListener('change', async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- setStatus('Importing ZIP...', 'yellow');
- try {
- const formData = new FormData();
- formData.append('zip', file);
- // Read as base64 and send as JSON since our server uses JSON
- const reader = new FileReader();
- reader.onload = async () => {
- await ensureWorkspaceForImport(file.name);
- const base64 = reader.result.split(',')[1];
- const res = await fetch('/api/upload-zip', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ data: base64, filename: file.name })
- });
- const data = await res.json();
- if (data.error) { setStatus(data.error, 'red'); return; }
- setStatus(`Imported ZIP ${file.name}`, 'green');
- await loadFileTree();
- await loadProjectInfo();
- // Auto-switch to Code tab and open first file
- autoOpenFirstFile();
- };
- reader.readAsDataURL(file);
- } catch (e) { setStatus('ZIP import failed', 'red'); }
- e.target.value = '';
- });
- // File tree context menu
- function showFileCtxMenu(e, fpath) {
- e.preventDefault();
- e.stopPropagation();
- ctxMenuTarget = fpath;
- const menu = $('fileCtxMenu');
- menu.style.left = e.clientX + 'px';
- menu.style.top = e.clientY + 'px';
- menu.classList.add('open');
- }
- function ctxOpenFile() {
- $('fileCtxMenu').classList.remove('open');
- if (ctxMenuTarget) openFile(ctxMenuTarget);
- }
- function ctxDeleteFile() {
- $('fileCtxMenu').classList.remove('open');
- if (ctxMenuTarget && confirm(`Delete ${ctxMenuTarget}?`)) deleteFile(ctxMenuTarget);
- }
- // Close context menu on click elsewhere
- document.addEventListener('click', () => $('fileCtxMenu').classList.remove('open'));
- // ===================== DRAG-AND-DROP =====================
- let dragCounter = 0;
- document.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; $('dropOverlay').classList.add('active'); });
- document.addEventListener('dragleave', e => { e.preventDefault(); if (--dragCounter <= 0) { dragCounter = 0; $('dropOverlay').classList.remove('active'); } });
- document.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
- document.addEventListener('drop', async (e) => {
- e.preventDefault(); dragCounter = 0; $('dropOverlay').classList.remove('active');
- // Check if drop is on chat input area (handled separately for images)
- if (e.target.closest('.chat-input-area')) return;
- const items = e.dataTransfer.items;
- const dtFiles = e.dataTransfer.files;
- if (!items?.length && !dtFiles?.length) return;
- setStatus('Reading dropped files...', 'yellow');
- // Handle ZIP files dropped directly
- for (const f of dtFiles) {
- if (f.name.endsWith('.zip')) {
- const reader = new FileReader();
- reader.onload = async () => {
- await ensureWorkspaceForImport(f.name);
- const base64 = reader.result.split(',')[1];
- try {
- const res = await fetch('/api/upload-zip', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ data: base64, filename: f.name })
- });
- const data = await res.json();
- if (data.error) { setStatus(data.error, 'red'); return; }
- setStatus(`Imported ZIP ${f.name}`, 'green');
- await loadFileTree();
- await loadProjectInfo();
- autoOpenFirstFile();
- } catch { setStatus('ZIP import failed', 'red'); }
- };
- reader.readAsDataURL(f);
- return;
- }
- }
- const files = [];
- // Accept all common code/text file types
- const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
- const isCodeFile = (name) => {
- const ext = name.includes('.') ? '.' + name.split('.').pop().toLowerCase() : '';
- return !ext || codeExts.includes(ext); // extensionless files like Makefile are also ok
- };
- if (items[0]?.getAsFileSystemHandle) {
- try {
- for (const item of items) {
- const handle = await item.getAsFileSystemHandle();
- if (handle.kind === 'directory') files.push(...await readDirHandle(handle, ''));
- else if (handle.kind === 'file' && isCodeFile(handle.name)) {
- const content = await (await handle.getFile()).text();
- files.push({ path: handle.name, content });
- }
- }
- await uploadFiles(files); return;
- } catch {}
- }
- const entries = [...items].map(i => i.webkitGetAsEntry?.()).filter(Boolean);
- if (entries.length) {
- async function readEntry(entry, prefix) {
- return new Promise(resolve => {
- if (entry.isFile) {
- if (isCodeFile(entry.name)) {
- entry.file(f => { const r = new FileReader(); r.onload = () => {
- files.push({ path: prefix ? `${prefix}/${entry.name}` : entry.name, content: r.result }); resolve();
- }; r.readAsText(f); });
- } else resolve();
- } else if (entry.isDirectory) {
- const dr = entry.createReader();
- dr.readEntries(async subs => { const ep = prefix ? `${prefix}/${entry.name}` : entry.name; for (const s of subs) await readEntry(s, ep); resolve(); });
- } else resolve();
- });
- }
- for (const entry of entries) await readEntry(entry, '');
- await uploadFiles(files);
- }
- });
- // ===================== ASK USER QUESTION (Interactive Choices) =====================
- function showAskUserWidget(data) {
- const container = $('chatMessages');
- const div = document.createElement('div');
- div.className = 'ask-user-widget';
- const inputType = data.multiSelect ? 'checkbox' : 'radio';
- let optionsHtml = '';
- for (let i = 0; i < data.options.length; i++) {
- const opt = data.options[i];
- optionsHtml += `<div class="ask-user-option" onclick="toggleAskOption(this, '${inputType}')">
- <input type="${inputType}" name="ask-opt" value="${i}">
- <div><div class="opt-label">${escapeHtml(opt.label)}</div>${opt.description ? `<div class="opt-desc">${escapeHtml(opt.description)}</div>` : ''}</div>
- </div>`;
- }
- div.innerHTML = `
- <div class="ask-question">${escapeHtml(data.question)}</div>
- ${optionsHtml}
- <div class="ask-user-other"><input type="text" id="askOtherInput" placeholder="Or type your own answer..."></div>
- <div class="ask-user-submit"><button onclick="submitAskAnswer(this.closest('.ask-user-widget'))">Submit</button></div>`;
- container.appendChild(div);
- scrollChat();
- }
- function toggleAskOption(optEl, type) {
- if (type === 'radio') {
- optEl.closest('.ask-user-widget').querySelectorAll('.ask-user-option').forEach(el => el.classList.remove('selected'));
- }
- optEl.classList.toggle('selected');
- optEl.querySelector('input').checked = optEl.classList.contains('selected');
- }
- async function submitAskAnswer(widget) {
- const otherInput = widget.querySelector('#askOtherInput').value.trim();
- if (otherInput) {
- await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer: otherInput, chatId: activeConvId }) });
- } else {
- const selected = [...widget.querySelectorAll('input:checked')].map(inp => {
- const opt = inp.closest('.ask-user-option').querySelector('.opt-label');
- return opt ? opt.textContent : '';
- }).filter(Boolean);
- const answer = selected.length ? selected.join(', ') : 'No selection';
- await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer, chatId: activeConvId }) });
- }
- // Dim the widget after answering
- widget.style.opacity = '0.5';
- widget.querySelector('.ask-user-submit button').disabled = true;
- }
- // ===================== SKILL PALETTE =====================
- let cachedSkills = null;
- let skillIdx = -1;
- // Client-side commands (instant, no LLM)
- const CLIENT_COMMANDS = [
- { name: 'help', description: 'Show all available commands' },
- { name: 'clear', description: 'Clear conversation context (fresh start)' },
- { name: 'context', description: 'Show context window usage' },
- { name: 'compile', description: 'Compile project and get preview URLs' },
- { name: 'status', description: 'Show project status summary' },
- { name: 'docs', description: 'Sync VL reference docs from DocCenter' },
- { name: 'version', description: 'Show VL-Code version' },
- { name: 'screenshot', description: 'Take IDE screenshot (self-test via Playwright)' },
- { name: 'console', description: 'Show browser console logs (errors/warnings)' },
- { name: 'inspect', description: 'Evaluate JS expression in browser context' },
- { name: 'test', description: 'List VL component instance-ids in compiled preview' },
- { name: 'syntax', description: 'Look up VL syntax reference (widget, section, rules)' },
- { name: 'cookie', description: 'Refresh cloud cookie from global auth or paste a new one' },
- { name: 'compile-errors', description: 'Show last compile errors from parsevl' },
- ];
- function handleClientCommand(name, args) {
- switch (name) {
- case 'help': {
- const cmds = CLIENT_COMMANDS.map(c => ` **/${c.name}** — ${c.description}`).join('\n');
- const skills = (cachedSkills || []).map(s => ` **/${s.name}** — ${s.description}`).join('\n');
- addMsg('assistant', `**Available Commands**\n\n_Client commands (instant):_\n${cmds}\n\n_AI skills (LLM-powered):_\n${skills || ' Loading...'}`);
- return true;
- }
- case 'clear':
- fetch('/api/conversations', { method: 'DELETE' }).catch(() => {});
- $('chatMessages').innerHTML = '';
- _lastBackendMsgCount = 0;
- conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
- activeConvId = 0; convIdCounter = 1;
- localStorage.removeItem(chatStorageKey());
- saveChatState();
- renderConvTabs();
- addMsg('assistant', 'Context cleared. Starting fresh conversation.');
- return true;
- case 'context':
- updateContext();
- api('/api/context').then(ctx => {
- const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
- addMsg('assistant', `**Context Usage:** ${(ctx.usedTokens/1000).toFixed(1)}K / ${(ctx.maxTokens/1000).toFixed(0)}K tokens (${pct}%)\nTurns: ${ctx.turnCount || 0}\nCache read: ${(ctx.cacheRead/1000).toFixed(1)}K | Cache write: ${(ctx.cacheCreation/1000).toFixed(1)}K`);
- });
- return true;
- case 'compile':
- compileProject();
- return true;
- case 'status':
- api('/api/project').then(proj => {
- const s = proj.summary || {};
- const files = s.totalFiles || 0;
- const types = s.filesByType ? Object.entries(s.filesByType).map(([k,v]) => `${k}: ${v}`).join(', ') : 'none';
- addMsg('assistant', `**Project Status**\nName: ${s.projectName || 'Unknown'}\nDirectory: ${proj.workDir}\nVL Project: ${proj.isVL ? 'Yes' : 'No'}\nFiles: ${files} (${types})\nModel: ${proj.model}\nVersion: v${proj.version}`);
- });
- return true;
- case 'docs':
- syncVLDocs();
- return true;
- case 'version':
- api('/api/version').then(v => addMsg('assistant', `VL-Code **v${v.version}**`));
- return true;
- case 'screenshot': {
- addMsg('assistant', 'Taking screenshot via Playwright...');
- fetch('/api/browser/screenshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: args || 'ide_' + Date.now() }) })
- .then(r => r.json()).then(data => {
- if (data.error) addMsg('assistant', 'Screenshot error: ' + data.error);
- else addMsg('assistant', `Screenshot saved: **${data.path}**\nSize: ${data.size}\n\n_View at: /api/browser/screenshot/${(args || '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'ide_' + Date.now()}_`);
- }).catch(e => addMsg('assistant', 'Screenshot failed: ' + e.message));
- return true;
- }
- case 'console': {
- fetch('/api/browser/console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filter: args || 'error' }) })
- .then(r => r.json()).then(data => {
- if (!data.logs?.length) { addMsg('assistant', `No ${args || 'error'} logs found.`); return; }
- const lines = data.logs.map(l => `[${l.type}] ${l.text}`).join('\n');
- addMsg('assistant', `**Browser Console** (${data.logs.length}/${data.total}):\n\`\`\`\n${lines}\n\`\`\``);
- }).catch(e => addMsg('assistant', 'Console fetch failed: ' + e.message));
- return true;
- }
- case 'inspect': {
- if (!args) { addMsg('assistant', 'Usage: /inspect <js expression>'); return true; }
- fetch('/api/browser/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expression: args }) })
- .then(r => r.json()).then(data => {
- if (data.error) addMsg('assistant', 'Eval error: ' + data.error);
- else addMsg('assistant', `**Result:**\n\`\`\`\n${data.result}\n\`\`\``);
- }).catch(e => addMsg('assistant', 'Inspect failed: ' + e.message));
- return true;
- }
- case 'test': {
- addMsg('assistant', 'Scanning VL components in compiled preview...');
- const previewUrl = args || null;
- const body = previewUrl ? { action: 'open', url: previewUrl } : { action: 'listIds' };
- // If a URL is provided, open it first then listIds
- if (previewUrl) {
- fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open', url: previewUrl }) })
- .then(r => r.json()).then(openRes => {
- if (openRes.error) { addMsg('assistant', 'Open failed: ' + openRes.error); return; }
- return fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) }).then(r => r.json());
- }).then(data => {
- if (!data) return;
- if (data.error) { addMsg('assistant', 'listIds error: ' + data.error); return; }
- const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
- addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
- }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
- } else {
- fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) })
- .then(r => r.json()).then(data => {
- if (data.error) { addMsg('assistant', 'listIds error: ' + data.error + '\n\n_Usage: /test <preview-url> to open a VL preview first_'); return; }
- const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
- addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
- }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
- }
- return true;
- }
- case 'compile-errors': {
- fetch('/api/compile/errors').then(r => r.json()).then(data => {
- if (!data.errList?.length) {
- addMsg('assistant', data.message || 'No compile errors. Last compile was clean.');
- return;
- }
- const errLines = data.errList.map((e, i) => {
- if (typeof e === 'string') return ` ${i + 1}. ${e}`;
- if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
- return ` ${i + 1}. ${JSON.stringify(e)}`;
- }).join('\n');
- addMsg('assistant', `**Last Compile Errors** (${data.errCount}) — ${data.timestamp || 'unknown time'}\nGID: ${data.gid || 'N/A'}\n\n${errLines}\n\n_Ask the AI to debug these errors, or use /debug to auto-analyze._`);
- }).catch(e => addMsg('assistant', 'Failed to fetch compile errors: ' + e.message));
- return true;
- }
- case 'syntax': {
- if (!args) { addMsg('assistant', 'Usage:\n **/syntax rules** — show VL generation hard rules\n **/syntax widget Button** — look up a widget\n **/syntax <keyword>** — search the VL reference'); return true; }
- const parts = args.trim().split(/\s+/);
- let body;
- if (parts[0] === 'rules') body = { action: 'rules' };
- else if (parts[0] === 'widget' && parts[1]) body = { action: 'widget', query: parts.slice(1).join(' ') };
- else if (parts[0] === 'section' && parts[1]) body = { action: 'section', query: parts.slice(1).join(' ') };
- else body = { action: 'search', query: args.trim() };
- addMsg('assistant', `Looking up VL syntax: **${args.trim()}**...`);
- fetch('/api/vl-syntax', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
- .then(r => r.json()).then(data => {
- if (data.error) { addMsg('assistant', 'Syntax lookup error: ' + data.error + (data.availableSections ? '\n\nAvailable sections: ' + data.availableSections.map(s => s.id).join(', ') : '') + (data.candidates ? '\n\nDid you mean: ' + data.candidates.map(c => c.heading).join(', ') : '')); return; }
- if (data.content) { addMsg('assistant', `**VL Reference: ${data.title || data.widget || data.section || args}**\n\n\`\`\`vl\n${data.content.slice(0, 3000)}\n\`\`\`${data.content.length > 3000 ? '\n\n_(truncated — use more specific query for full content)_' : ''}`); return; }
- if (data.results) { const lines = data.results.map(r => `**Line ${r.line}**: ${r.match}`).join('\n'); addMsg('assistant', `**Search: "${data.query}"** (${data.totalMatches} matches)\n\n${lines}`); return; }
- if (data.availableSections) { addMsg('assistant', `**Available sections:**\n${data.availableSections.map(s => ` - **${s.id}** — ${s.title}`).join('\n')}`); return; }
- addMsg('assistant', '```json\n' + JSON.stringify(data, null, 2) + '\n```');
- }).catch(e => addMsg('assistant', 'Syntax lookup failed: ' + e.message));
- return true;
- }
- case 'cookie': {
- if (args && args.trim().length > 20) {
- // User pasted a cookie directly: /cookie <jwt>
- addMsg('assistant', 'Setting cookie...');
- fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cookie: args.trim() }) })
- .then(r => r.json()).then(d => {
- if (d.ok) { addMsg('assistant', 'Cookie updated. Cloud features ready.'); initCloudStatus(); }
- else addMsg('assistant', 'Failed to set cookie: ' + (d.error || 'unknown'));
- }).catch(e => addMsg('assistant', 'Error: ' + e.message));
- } else {
- // Refresh cookie from global auth
- addMsg('assistant', 'Refreshing cookie from global auth...');
- fetch('/api/cookie/refresh', { method: 'POST' })
- .then(r => r.json()).then(d => {
- if (d.ok) { addMsg('assistant', `Cookie refreshed (source: ${d.source || 'auth.json'}). User: **${d.userId || '?'}**`); initCloudStatus(); }
- else addMsg('assistant', 'No cookie found. Use `/cookie <jwt>` to paste one, or login via Cloud panel.');
- }).catch(e => addMsg('assistant', 'Error: ' + e.message));
- }
- return true;
- }
- default:
- return false;
- }
- }
- async function showSkillPalette() {
- if (!cachedSkills) {
- try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
- }
- const palette = $('skillPalette');
- palette.innerHTML = '';
- skillIdx = -1;
- const input = $('chatInput').value.slice(1).toLowerCase();
- // Combine client commands + server skills
- const all = [
- ...CLIENT_COMMANDS.map(c => ({ ...c, isClient: true })),
- ...(cachedSkills || []).map(s => ({ ...s, isClient: false })),
- ];
- const filtered = all.filter(s => s.name.includes(input) || s.description.toLowerCase().includes(input));
- for (const skill of filtered) {
- const item = document.createElement('div');
- item.className = 'skill-item';
- const badge = skill.isClient ? '<span style="font-size:8px;color:var(--green);margin-left:2px;">●</span>' : '';
- item.innerHTML = `<span class="sk-name">/${escapeHtml(skill.name)}${badge}</span><span class="sk-desc">${escapeHtml(skill.description)}</span>`;
- item.onclick = () => selectSkill(skill.name);
- palette.appendChild(item);
- }
- if (filtered.length > 0) palette.classList.add('open');
- else palette.classList.remove('open');
- }
- function selectSkill(name) {
- $('chatInput').value = `/${name} `;
- $('chatInput').focus();
- $('skillPalette').classList.remove('open');
- }
- // Extend chatInput handler to detect / commands
- const origInputHandler = $('chatInput').oninput;
- $('chatInput').addEventListener('input', function() {
- const val = this.value;
- if (val.startsWith('/') && !val.includes(' ')) {
- showSkillPalette();
- } else {
- $('skillPalette').classList.remove('open');
- }
- });
- // ===================== CONVERSATION SEARCH =====================
- function openChatSearch() {
- $('chatSearch').classList.add('open');
- $('chatSearchInput').focus();
- }
- function closeChatSearch() {
- $('chatSearch').classList.remove('open');
- $('chatSearchInput').value = '';
- $('searchCount').textContent = '';
- // Remove highlights
- $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
- el.replaceWith(el.textContent);
- });
- }
- function searchConversation(query) {
- // Remove old highlights
- $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
- el.replaceWith(el.textContent);
- });
- if (!query || query.length < 2) { $('searchCount').textContent = ''; return; }
- let count = 0;
- const msgs = $('chatMessages').querySelectorAll('.msg .content-text');
- const q = query.toLowerCase();
- for (const el of msgs) {
- if (el.textContent.toLowerCase().includes(q)) {
- count++;
- // Highlight matches (simple text node replacement)
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
- const textNodes = [];
- while (walker.nextNode()) textNodes.push(walker.currentNode);
- for (const node of textNodes) {
- if (node.textContent.toLowerCase().includes(q)) {
- const parts = node.textContent.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'));
- if (parts.length > 1) {
- const span = document.createElement('span');
- for (const part of parts) {
- if (part.toLowerCase() === q) {
- const mark = document.createElement('mark');
- mark.className = 'search-highlight';
- mark.style.cssText = 'background:var(--yellow);color:var(--bg);border-radius:2px;padding:0 1px;';
- mark.textContent = part;
- span.appendChild(mark);
- } else {
- span.appendChild(document.createTextNode(part));
- }
- }
- node.replaceWith(span);
- }
- }
- }
- }
- }
- $('searchCount').textContent = count > 0 ? `${count} found` : 'No matches';
- }
- // Cmd/Ctrl+F in chat: open search
- document.addEventListener('keydown', e => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'f' && document.activeElement?.closest('.chat-panel')) {
- e.preventDefault();
- openChatSearch();
- }
- });
- // ===================== EXTEND SENDMESSAGE FOR SKILLS =====================
- const origSendMessage = sendMessage;
- sendMessage = async function() {
- const input = $('chatInput');
- const msg = input.value.trim();
- $('skillPalette').classList.remove('open');
- // Handle skill commands
- if (msg.startsWith('/') && !msg.startsWith('//')) {
- const parts = msg.substring(1).split(/\s+/);
- const skillName = parts[0];
- const args = parts.slice(1).join(' ');
- // Client-side commands (no LLM needed)
- const clientCmd = handleClientCommand(skillName, args);
- if (clientCmd) { input.value = ''; return; }
- // Check if it's a known skill
- if (!cachedSkills) {
- try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
- }
- const skill = cachedSkills.find(s => s.name === skillName);
- if (skill) {
- input.value = '';
- $('chatSend').disabled = true;
- $('chatSend').style.display = 'none';
- $('chatStop').style.display = '';
- _currentAbortController = new AbortController();
- setStatus(`Running /${skillName}...`, 'yellow');
- addMsg('user', msg);
- try {
- const res = await fetch('/api/skill', {
- method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ skill: skillName, args, chatId: activeConvId }),
- signal: _currentAbortController?.signal,
- });
- startSpinnerSafetyTimeout();
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let assistantEl = null;
- let buffer = '';
- let currentEvent = '';
- while (true) {
- const {done, value} = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, {stream:true});
- const lines = buffer.split('\n');
- buffer = lines.pop();
- for (const line of lines) {
- if (line.startsWith('event: ')) { currentEvent = line.slice(7); continue; }
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6));
- debugLog(currentEvent || 'data', data);
- if (currentEvent === 'thinking') {
- if (data.phase === 'start') addThinkingIndicator();
- else if (data.phase === 'delta' && data.text) appendThinkingText(data.text);
- else if (data.phase === 'end') finalizeThinking();
- } else if (currentEvent === 'ask_user') {
- showAskUserWidget(data);
- } else if (currentEvent === 'plan_mode') {
- handlePlanModeEvent(data);
- } else if (data.text) {
- if (!assistantEl) { assistantEl = addMsg('assistant', ''); assistantEl.querySelector('.content-text').dataset.raw = ''; }
- const textEl = assistantEl.querySelector('.content-text');
- textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
- textEl.textContent += data.text;
- scrollChat();
- } else if (data.name && data.input !== undefined) {
- addToolIndicator(data.name, data.input, 'running', data.detail);
- } else if (data.name && data.preview !== undefined) {
- updateToolIndicator(data.name, data.preview);
- } else if (data.todos) {
- renderTodos(data.todos);
- } else if (currentEvent === 'workflow_generated') {
- if (data.workflow) {
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
- });
- }
- } else if (currentEvent === 'node_start') {
- forwardWorkflowEventToIframe('node_start', data);
- } else if (currentEvent === 'node_done') {
- forwardWorkflowEventToIframe('node_done', data);
- } else if (currentEvent === 'node_error') {
- forwardWorkflowEventToIframe('node_error', data);
- } else if (currentEvent === 'screenshot' && data.screenshots?.length) {
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- for (const ssName of data.screenshots) {
- const url = `/api/browser/screenshot/${ssName}`;
- appendScreenshotToChat(assistantEl, url, ssName);
- _contextScreenshots.push({ url, name: ssName });
- }
- } else if (currentEvent === 'done') {
- finalizeAssistantMsg(assistantEl);
- finalizeAllToolSpinners();
- clearSpinnerSafetyTimeout();
- if (data.msgCount !== undefined) _lastBackendMsgCount = data.msgCount;
- } else if (currentEvent === 'error') {
- if (!assistantEl) assistantEl = addMsg('assistant', '');
- assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
- }
- } catch {}
- }
- }
- }
- } catch(e) {
- if (e.name === 'AbortError') {
- addMsg('assistant', '⏹ Stopped by user.');
- } else {
- addMsg('assistant', 'Skill error: ' + e.message);
- }
- finalizeAllToolSpinners();
- }
- clearSpinnerSafetyTimeout();
- _currentAbortController = null;
- $('chatStop').style.display = 'none';
- $('chatSend').style.display = '';
- $('chatSend').disabled = false;
- setStatus('Ready', 'green');
- updateContext();
- return;
- }
- }
- // Fall through to original sendMessage
- return origSendMessage.call(this);
- };
- // Also handle ask_user events in the main chat SSE stream
- const origSSEParseLine = null; // We need to modify the sendMessage SSE handler
- // Patch: intercept ask_user events in main sendMessage flow
- // This is done by modifying the sendMessage function's SSE parsing
- // The easiest approach: also check for ask_user in the main sendMessage
- // Let's patch it by adding ask_user handling to the main SSE loop
- // ===================== SPECIAL TAB HELPERS =====================
- /** Open workflow DAG tab */
- function openWorkflowTab(workflowData, title) {
- openSpecialTab('__workflow__', 'workflow', title || 'Workflow DAG', workflowData);
- }
- /** Open metadata visualization tab */
- function openMetadataTab(metaData, title) {
- if (currentMode === 'meta') {
- showModeIframe('metadata', '/metadata-viewer.html', async () =>
- metaData ? { type: 'loadMetadata', data: metaData } : null
- );
- } else {
- _setMapIndicator(!!metaData);
- }
- }
- /** Send message to a special tab iframe */
- function postToSpecialTab(key, message) {
- const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
- if (iframe?.contentWindow) iframe.contentWindow.postMessage(message, '*');
- }
- /** Update workflow node status (called during workflow execution) */
- function updateWorkflowNode(nodeId, status) {
- const msg = { type: 'updateNodeStatus', nodeId, status };
- // Try both special-tab and mode-iframe keys
- postToSpecialTab('__workflow__', msg);
- sendToWorkflowIframe(msg);
- }
- // ===================== WORKFLOW MANAGEMENT =====================
- let cachedWorkflows = null;
- let activeWorkflowName = null;
- let _selectedCodegenWorkflow = 'parallel'; // server-persisted default
- const CODEGEN_WORKFLOWS = {
- 'parallel': { label: 'Parallel', desc: 'Default: Theme.vth + fully parallel VL fanout', file: 'parallel-codegen' },
- 'meta-direct': { label: 'Meta-Direct', desc: 'Small projects: direct ProjectMeta + parallel VL fanout', file: 'meta-direct-codegen' },
- '3-file': { label: '3-File', desc: 'Medium: PRD + ServiceMap + UIMap + Theme.vth', file: '3-file-codegen' },
- '6-file': { label: '6-File', desc: 'Medium-large: 6 specs + Theme.vth + parallel VL fanout', file: '6-file-codegen' },
- '9-file': { label: '9-File', desc: 'Large: 9 specs + Theme.vth + parallel VL fanout', file: '9-file-codegen' },
- };
- const ADJUST_WORKFLOWS = {
- 'add-page': { label: 'Add Page', desc: 'Add new page: section + components + route', file: 'add-page' },
- 'add-service': { label: 'Add Service', desc: 'Add new backend service domain + DB schema', file: 'add-service' },
- 'theme-customize': { label: 'Theme', desc: 'Customize theme tokens + cascade updates', file: 'theme-customize' },
- 'general': { label: 'General', desc: 'General changes via Meta diff + affected file regen', file: 'incremental-update' },
- };
- function toggleWorkflowPanel() {
- const dd = $('wfDropdown');
- dd.classList.toggle('open');
- if (dd.classList.contains('open')) {
- renderCodegenOptions();
- renderAdjustOptions();
- loadWorkflowList();
- }
- }
- function toggleWfAllList() {
- const list = $('wfList');
- const toggle = $('wfAllToggle');
- if (list.style.display === 'none') {
- list.style.display = 'block';
- toggle.innerHTML = '▼';
- } else {
- list.style.display = 'none';
- toggle.innerHTML = '▶';
- }
- }
- function renderCodegenOptions() {
- const container = $('wfCodegenOptions');
- container.innerHTML = '';
- for (const [key, info] of Object.entries(CODEGEN_WORKFLOWS)) {
- const div = document.createElement('div');
- div.className = 'wf-item';
- div.style.cursor = 'pointer';
- if (key === _selectedCodegenWorkflow) {
- div.style.background = 'var(--accent)';
- div.style.color = '#fff';
- div.style.borderRadius = '4px';
- }
- div.innerHTML = `<div style="flex:1"><span class="wf-name" style="${key === _selectedCodegenWorkflow ? 'color:#fff' : ''}">${info.label}</span><div style="font-size:9px;color:${key === _selectedCodegenWorkflow ? 'rgba(255,255,255,0.7)' : 'var(--text2)'};margin-top:2px;">${info.desc}</div></div><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${info.file}')" style="${key === _selectedCodegenWorkflow ? 'color:rgba(255,255,255,0.8)' : ''}">View</span>`;
- div.onclick = () => selectCodegenWorkflow(key);
- container.appendChild(div);
- }
- }
- function renderAdjustOptions() {
- const container = $('wfAdjustOptions');
- container.innerHTML = '';
- for (const [key, info] of Object.entries(ADJUST_WORKFLOWS)) {
- const div = document.createElement('div');
- div.className = 'wf-item';
- div.style.cursor = 'pointer';
- div.innerHTML = `<div style="flex:1"><span class="wf-name">${info.label}</span><div style="font-size:9px;color:var(--text2);margin-top:2px;">${info.desc}</div></div><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${info.file}')">View</span>`;
- div.onclick = () => { $('wfDropdown').classList.remove('open'); setStatus(`Adjustment workflow: ${info.label} (used automatically by VLAdjust tool)`, 'green'); };
- container.appendChild(div);
- }
- }
- async function selectCodegenWorkflow(key) {
- _selectedCodegenWorkflow = key;
- $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[key].label;
- $('wfDropdown').classList.remove('open');
- // Persist on server
- try { await api('/api/workflow-selection', { method: 'POST', body: JSON.stringify({ workflow: key }), headers: { 'Content-Type': 'application/json' } }); } catch {}
- // Also update local binding
- workflowBindings.generate = CODEGEN_WORKFLOWS[key].file;
- localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
- setStatus(`Codegen workflow: ${CODEGEN_WORKFLOWS[key].label}`, 'green');
- }
- async function loadCodegenWorkflowSelection() {
- try {
- const data = await api('/api/workflow-selection');
- if (data.defaultWorkflow && CODEGEN_WORKFLOWS[data.defaultWorkflow]) {
- _selectedCodegenWorkflow = data.defaultWorkflow;
- $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.defaultWorkflow].label;
- workflowBindings.generate = CODEGEN_WORKFLOWS[data.defaultWorkflow].file;
- }
- } catch {}
- }
- // Close workflow panel on click outside
- document.addEventListener('click', e => {
- if (!e.target.closest('.wf-selector')) $('wfDropdown').classList.remove('open');
- });
- async function loadWorkflowList() {
- try {
- const data = await api('/api/workflows');
- cachedWorkflows = data.workflows;
- const list = $('wfList');
- list.innerHTML = '';
- if (!data.workflows.length) {
- list.innerHTML = '<div style="padding:8px 10px;font-size:10px;color:var(--text2);">No workflows found</div>';
- return;
- }
- for (const wf of data.workflows) {
- const div = document.createElement('div');
- div.className = 'wf-item';
- if (wf.name === activeWorkflowName) div.style.background = 'var(--bg3)';
- div.innerHTML = `<span class="wf-name">${escapeHtml(wf.title || wf.name)}</span><span class="wf-steps">${wf.stepCount} steps</span><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${escapeHtml(wf.name)}')">View</span>`;
- div.onclick = () => { activeWorkflowName = wf.name; $('wfDropdown').classList.remove('open'); setStatus('Workflow: ' + (wf.title || wf.name), 'green'); };
- list.appendChild(div);
- }
- } catch { $('wfList').innerHTML = '<div style="padding:8px;font-size:10px;color:var(--red)">Failed to load</div>'; }
- }
- async function viewWorkflow(name) {
- try {
- const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
- const wf = data.workflow || (data.steps ? data : null);
- if (wf) {
- // Switch to Flow tab and load the workflow for viewing
- if (currentMode !== 'flow') switchMode('flow');
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: wf, workflowName: name };
- });
- }
- } catch (e) { setStatus('Failed to load workflow', 'red'); }
- $('wfDropdown').classList.remove('open');
- }
- async function viewMetadataTab() {
- $('wfDropdown').classList.remove('open');
- try {
- // Check if VL project first
- const proj = await api('/api/project');
- if (!proj.isVL) {
- setStatus('No VL files — cannot extract metadata', 'yellow');
- switchMode('meta');
- openMetadataTab(null, 'Project Meta');
- return;
- }
- let data = await api('/api/metadata');
- if (!_hasRenderableMetadata(data.meta)) {
- setStatus('Extracting metadata...', 'yellow');
- data = await api('/api/metadata/extract');
- if (data.meta) setStatus('Metadata extracted', 'green');
- else setStatus('No metadata found', 'yellow');
- }
- switchMode('meta');
- openMetadataTab(data.meta || null, 'Project Meta');
- } catch {
- switchMode('meta');
- openMetadataTab(null, 'Project Meta');
- }
- }
- // ===================== MODE TABS =====================
- /** Show/hide green dot on Map tab indicating metadata is available */
- function _setMapIndicator(show) {
- const dot = $('mapReadyDot');
- if (dot) dot.style.display = show ? 'inline-block' : 'none';
- }
- function _hasRenderableMetadata(meta) {
- if (!meta || typeof meta !== 'object') return false;
- if ((meta.apps || []).length > 0) return true;
- if ((meta.sections || []).length > 0) return true;
- if ((meta.components || []).length > 0) return true;
- if ((meta.services || meta.serviceDomains || []).length > 0) return true;
- if ((meta.dataSchema?.tables || meta.tables || meta.database?.tables || []).length > 0) return true;
- return false;
- }
- function buildDocCenterEmbedSrc({ embed = 'ide', docId = null, force = false } = {}) {
- const params = new URLSearchParams();
- if (embed) params.set('embed', embed);
- const normalizedDocId = normalizeDocRefInput(docId);
- if (normalizedDocId) params.set('docId', String(normalizedDocId));
- if (force) params.set('t', String(Date.now()));
- return `/doc-center.html?${params.toString()}`;
- }
- async function resolveDocCenterEmbedSrc({ force = false, embed = 'ide', docId = _docCenterFocusDocId } = {}) {
- return buildDocCenterEmbedSrc({ embed, docId, force });
- }
- async function showDocCenterMode(docId = _docCenterFocusDocId) {
- $('editorTabs').style.display = 'none';
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- $('editorPlaceholder').style.display = 'none';
- $('iframeContainer').style.display = 'block';
- const src = await resolveDocCenterEmbedSrc({ docId });
- showModeIframe('docs', src, async () => null);
- setStatus('Documentation ready', 'green');
- }
- /** Switch between Code / Map / Flow modes */
- function switchMode(mode) {
- // Clear map indicator when user visits Map tab
- if (mode === 'meta') _setMapIndicator(false);
- currentMode = mode;
- // Update mode tab styling
- document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
- if (mode === 'code') {
- // Show normal editor tabs + content — Code tab shows the selected file
- $('editorTabs').style.display = 'flex';
- $('iframeContainer').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- if (currentFile && openFiles.has(currentFile)) {
- showTabContent(currentFile);
- } else if (openFiles.size > 0) {
- const keys = [...openFiles.keys()].filter(k => openFiles.get(k).type === 'file');
- if (keys.length > 0) { currentFile = keys[keys.length - 1]; renderTabs(); showTabContent(currentFile); }
- else { $('cmEditorWrap').style.display = 'none'; $('editor').style.display = 'none'; $('editorPlaceholder').style.display = 'block'; }
- } else {
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('editorPlaceholder').style.display = 'block';
- }
- } else if (mode === 'meta') {
- // Show metadata viewer
- $('editorTabs').style.display = 'none';
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('codePreview').style.display = 'none';
- $('mdPreview').style.display = 'none';
- $('editorPlaceholder').style.display = 'none';
- $('iframeContainer').style.display = 'block';
- showModeIframe('metadata', '/metadata-viewer.html', async () => {
- try {
- // Check if this is a VL project first
- const proj = await api('/api/project');
- if (!proj.isVL) {
- setStatus('No VL files — metadata extraction skipped', 'yellow');
- return null;
- }
- // Try loading existing metadata
- let data = await api('/api/metadata');
- // If no metadata, auto-extract from VL files
- if (!_hasRenderableMetadata(data.meta)) {
- setStatus('Extracting metadata from VL files...', 'yellow');
- data = await api('/api/metadata/extract');
- if (data.meta) setStatus('Metadata extracted', 'green');
- else setStatus('No metadata to extract', 'yellow');
- }
- return data.meta ? { type: 'loadMetadata', data: data.meta } : null;
- } catch { return null; }
- });
- } else if (mode === 'flow') {
- // Show workflow editor with Gen/Adjust toolbar
- $('editorTabs').style.display = 'none';
- $('previewBar').style.display = 'none';
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('editorPlaceholder').style.display = 'none';
- $('flowToolbar').style.display = 'flex';
- $('iframeContainer').style.display = 'block';
- populateFlowWorkflowSelect();
- loadActiveFlowWorkflow();
- } else if (mode === 'docs') {
- showDocCenterMode();
- } else if (mode === 'preview') {
- // Show live preview
- $('editorTabs').style.display = 'none';
- $('cmEditorWrap').style.display = 'none';
- $('editor').style.display = 'none';
- $('editorPlaceholder').style.display = 'none';
- $('previewBar').style.display = 'flex';
- $('iframeContainer').style.display = 'block';
- loadPreviewApp();
- }
- // Hide bars when not in their respective modes
- if (mode !== 'preview') $('previewBar').style.display = 'none';
- if (mode !== 'flow') $('flowToolbar').style.display = 'none';
- }
- // ===================== PREVIEW =====================
- let previewUrls = {}; // { appId: url }
- /** Activate preview mode with URLs */
- function activatePreview(urls) {
- previewUrls = urls || {};
- const keys = Object.keys(previewUrls);
- if (keys.length === 0) {
- $('previewUrlsPanel').style.display = 'none';
- $('previewUrlLabel').textContent = '';
- return;
- }
- // Show the Preview tab
- $('previewModeTab').style.display = '';
- // Populate app selector in preview bar
- const sel = $('previewAppSelect');
- sel.innerHTML = '';
- for (const [appId, url] of Object.entries(previewUrls)) {
- sel.innerHTML += `<option value="${appId}">${appId}</option>`;
- }
- sel.value = keys[0];
- $('previewUrlLabel').textContent = previewUrls[keys[0]] || '';
- // Populate sidebar preview URL list
- const list = $('previewUrlsList');
- list.innerHTML = '';
- for (const [appId, url] of Object.entries(previewUrls)) {
- const item = document.createElement('div');
- item.className = 'preview-url-item';
- item.innerHTML = `<span class="pui-name">${escapeHtml(appId)}</span><span class="pui-url">${escapeHtml(url)}</span>`;
- item.onclick = () => { window.open(url, '_blank'); };
- item.title = `Open ${url} in new tab`;
- list.appendChild(item);
- }
- $('previewUrlsPanel').style.display = 'block';
- // NOTE: Do NOT auto-switch to preview mode — user opens preview manually via Preview tab or sidebar links
- }
- function loadPreviewApp() {
- const appId = $('previewAppSelect').value;
- const url = previewUrls[appId];
- if (!url) return;
- $('previewUrlLabel').textContent = url;
- // Always open in browser — no iframe embedding (cross-origin blocked by VL platform)
- window.open(url, '_blank');
- }
- function refreshPreview() {
- // Re-open current preview app in browser
- const appId = $('previewAppSelect')?.value;
- const url = previewUrls[appId];
- if (url) window.open(url, '_blank');
- }
- function openPreviewExternal() {
- const appId = $('previewAppSelect').value;
- const url = previewUrls[appId];
- if (url) window.open(url, '_blank');
- }
- /** Load preview URLs from project profile (saved by VLParse) */
- async function loadPreviewUrlsFromProfile() {
- try {
- const profile = normalizeProjectProfile(await api('/api/profile'));
- if (profile.previewUrls && Object.keys(profile.previewUrls).length > 0) {
- activatePreview(profile.previewUrls);
- }
- } catch {}
- }
- /** Show/create a mode iframe (reused across switches) */
- function showModeIframe(type, src, getDataFn) {
- const container = $('iframeContainer');
- const key = `__mode_${type}__`;
- const resolvedSrc = new URL(src, window.location.href).href;
- // Hide all iframes
- [...container.children].forEach(f => f.style.display = 'none');
- let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
- const onLoad = async () => {
- const msg = await getDataFn();
- if (msg) iframe.contentWindow.postMessage(msg, '*');
- };
- if (!iframe) {
- iframe = document.createElement('iframe');
- iframe.dataset.tab = key;
- iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups';
- iframe.onload = onLoad;
- iframe.src = src;
- container.appendChild(iframe);
- } else {
- iframe.style.display = 'block';
- iframe.onload = onLoad;
- if (iframe.src !== resolvedSrc) {
- iframe.src = src;
- } else {
- // Refresh data
- getDataFn().then(msg => { if (msg) iframe.contentWindow.postMessage(msg, '*'); });
- }
- }
- iframe.style.display = 'block';
- }
- // ===================== FOLDER PATH =====================
- function openFolderInFinder() {
- if (!currentWorkDir) return;
- // Use backend to open folder (cross-platform)
- fetch('/api/open-folder', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: currentWorkDir }),
- }).catch(() => {});
- }
- // ===================== WORKFLOW BINDINGS =====================
- function loadWorkflowBindings() {
- try {
- const saved = localStorage.getItem('vl-code-wf-bindings');
- if (saved) workflowBindings = JSON.parse(saved);
- } catch {}
- // Also load server-persisted codegen selection
- loadCodegenWorkflowSelection();
- }
- function saveWorkflowBindings() {
- localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
- }
- // Listen for messages from embedded iframes
- window.addEventListener('message', (e) => {
- if (!e.data?.type) return;
- if (e.data.type === 'nodeClick') {
- // User clicked a node in workflow DAG — could highlight related file
- setStatus(`Node: ${e.data.nodeId || e.data.nodeName || 'unknown'}`, 'green');
- }
- if (e.data.type === 'metaNodeClick') {
- // User clicked a node in metadata graph — could navigate to related file
- setStatus(`Meta: ${e.data.nodeType}/${e.data.nodeName || 'unknown'}`, 'green');
- }
- });
- // Extend SSE handler for workflow/metadata events
- const origConnectSSE = connectSSE;
- connectSSE = function() {
- origConnectSSE(); // proper setup: sets _sseSource, reconnect, base handlers
- const es = _sseSource;
- if (!es) return;
- // On connect/reconnect: sync running workflow state from server so all tabs stay consistent
- setTimeout(async () => {
- try {
- const state = await api('/api/workflow/current-state');
- if (state.active && state.workflowName) {
- _workflowActive = true;
- _lastWorkflowName = state.workflowName;
- window._skipFlowAutoLoad = true;
- const wn = state.workflowName;
- const syncRunToken = state.clientRunToken || state.runID || `state:${wn}`;
- if (wn.startsWith('autotest')) switchFlowTab('autotest');
- else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
- else switchFlowTab('adjust');
- await populateFlowWorkflowSelect();
- _setFlowWfSelectOrStore(wn, $('flowWfSelect'));
- updateFlowWfList();
- await loadFlowWorkflow(wn);
- forwardWorkflowEventToIframe('workflow_start', {
- workflowName: wn,
- name: wn,
- runID: state.runID || null,
- clientRunToken: syncRunToken,
- });
- if (state.checkpoint) {
- sendToWorkflowIframe({
- type: 'setCheckpoint',
- checkpoint: state.checkpoint,
- runID: state.runID || null,
- clientRunToken: syncRunToken,
- });
- }
- // Replay node statuses into DAG
- for (const [nodeId, status] of Object.entries(state.nodeStatuses || {})) {
- sendToWorkflowIframe({
- type: 'updateNodeStatus',
- nodeId,
- status,
- runID: state.runID || null,
- clientRunToken: syncRunToken,
- });
- }
- updateChatStatusBar(`Running workflow: ${wn}...`, '');
- } else if (!state.active) {
- window._skipFlowAutoLoad = false;
- // Check localStorage for last workflow to restore view (not state) after refresh
- try {
- const saved = localStorage.getItem('vl-code-last-flow-wf');
- if (saved) {
- const { name, tab } = JSON.parse(saved);
- if (name && tab) {
- switchFlowTab(tab);
- await loadFlowWorkflow(name);
- }
- localStorage.removeItem('vl-code-last-flow-wf');
- }
- } catch {}
- }
- } catch {}
- }, 800);
- function ensureWorkflowBroadcastChat(workflowName, meta = {}) {
- const existing = window._wfBroadcastChatEl;
- if (existing && document.body.contains(existing)) return existing;
- const wfEl = addMsg('assistant', '');
- wfEl.dataset.wfChat = 'true';
- const header = `<div style="font-size:11px;color:var(--blue);padding:4px 0;font-weight:600;">▶ Workflow: ${escapeHtml(workflowName || 'running')}${meta.stepCount ? ' (' + escapeHtml(String(meta.stepCount)) + ' steps)' : ''}${meta.model ? ' — ' + escapeHtml(meta.model) : ''}</div>`;
- wfEl.querySelector('.content-text').innerHTML = header;
- window._wfBroadcastChatEl = wfEl;
- scrollChat();
- return wfEl;
- }
- function appendWorkflowBroadcastChatLine(text, style = '', lineId = '') {
- const wfEl = ensureWorkflowBroadcastChat(_lastWorkflowName || 'running');
- if (!wfEl) return null;
- const body = wfEl.querySelector('.content-text');
- if (!body) return null;
- if (lineId) {
- const existing = document.getElementById(lineId);
- if (existing) return existing;
- }
- const line = document.createElement('div');
- if (lineId) line.id = lineId;
- line.className = 'wf-chat-step';
- line.style.cssText = style || 'font-size:11px;color:var(--text2);padding:2px 0;';
- line.textContent = text;
- body.appendChild(line);
- scrollChat();
- return line;
- }
- // Add extended handlers (workflow/autotest/metadata) — additive via addEventListener
- es.addEventListener('message', (e) => {
- try {
- const data = JSON.parse(e.data);
- // ── Workflow execution start (from VLGenerate / WorkflowRun tools) ──
- // Auto-switch to Flow tab + load the workflow DAG for live visualization
- if (data.type === 'workflow_execution_start') {
- _workflowActive = true;
- addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || 'unknown'}`, null, 'info');
- updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
- if (data.workflow) {
- // Set _skipFlowAutoLoad flag to prevent switchMode('flow') from loading a different workflow
- window._skipFlowAutoLoad = true;
- // Load the actual workflow JSON into the Flow tab
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || null };
- });
- // Clear previous node statuses after iframe loads
- setTimeout(() => sendToWorkflowIframe({ type: 'clearStatus' }), 300);
- // Keep _skipFlowAutoLoad true while workflow is active (cleared on workflow_done/error)
- }
- }
- // ── Workflow node status updates ──
- // Detail panel: ALL node updates (full log)
- // Chat: only status bar update (no duplication)
- // Flow DAG: highlight animation
- if (data.type === 'workflow_node_update') {
- updateWorkflowNode(data.nodeId, data.status);
- sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: data.nodeId, status: data.status, runID: data.runID || null, clientRunToken: data.clientRunToken || null });
- // Skip addDetailEntry here — wf_node_start/done/error handlers create step cards instead
- // Only keep detail entries for autotest case-level updates (have caseId)
- if (data.caseId) {
- const nodeLabel = (data.nodeId || '').replace(/_/g, ' ');
- const statusIcon = data.status === 'done' ? '>' : data.status === 'error' ? 'x' : data.status === 'running' ? '>' : '-';
- const detailType = data.status === 'error' ? 'error' : data.status === 'done' ? 'success' : 'info';
- addDetailEntry('node', `${statusIcon} [${data.caseId}] ${nodeLabel} [${data.status}]`, null, detailType, { depth: 2 });
- }
- // Auto-load test-case sub-workflow when step nodes start running
- // STEP_xxx nodes indicate per-step progress — auto-switch to that test case's workflow
- if (data.caseId && data.nodeId?.startsWith('STEP_') && data.status === 'running' && data.nodeId === 'STEP_001') {
- const tcWfName = `autotest-tc-${(data.caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
- loadWorkflowIntoFlowTab(tcWfName);
- // Also select it in the autotest hierarchy
- _atActiveLevel = 'testcase'; _atActiveCase = data.caseId;
- const matchApp = _atApps.find(a => (a.cases || []).some(c => c.id === data.caseId));
- if (matchApp) _atActiveApp = matchApp.appId;
- updateAtWfList();
- }
- }
- // ── Workflow completed/failed (legacy broadcast — kept for backward compat, wf_done/wf_error are preferred) ──
- if (data.type === 'workflow_done' && !_workflowActive) {
- // Only handle if wf_done hasn't already processed it
- _setMapIndicator(true);
- }
- if (data.type === 'workflow_error' && !_workflowActive) {
- // Already handled by wf_error
- }
- // ── Rich workflow broadcast events (wf_*) — show step cards from ANY tab ──
- if (data.type === 'wf_start') {
- forwardWorkflowEventToIframe('workflow_start', data);
- _workflowActive = true;
- _lastWorkflowName = data.workflowName || '';
- _lastRunCheckpoint = null;
- for (const k in _stepCards) delete _stepCards[k];
- setChatStatusRunning(true);
- updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
- addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || ''} (${data.stepCount || '?'} steps) [${data.model || ''}]`, null, 'info');
- ensureWorkflowBroadcastChat(data.workflowName || '', { stepCount: data.stepCount || '?', model: data.model || '' });
- // Switch to correct sub-tab based on workflow name
- const wn = data.workflowName || '';
- if (wn.startsWith('autotest')) switchFlowTab('autotest');
- else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
- else switchFlowTab('adjust');
- // Persist running workflow for refresh-restore
- try { localStorage.setItem('vl-code-last-flow-wf', JSON.stringify({ name: wn, tab: currentFlowTab })); } catch {}
- // Keep workflow data refreshed in the background without stealing focus
- if (_lastWorkflowName) loadWorkflowIntoFlowTab(_lastWorkflowName);
- }
- if (data.type === 'wf_node_start') {
- forwardWorkflowEventToIframe('node_start', data);
- const nodeType = data.nodeType || '';
- const nodeTitle = data.title || data.nodeId || '?';
- addStepCard(data.nodeId, nodeType, nodeTitle, data.resolvedInputs || data.input);
- updateChatStatusBar(`Running ${nodeTitle}...`, '');
- updateWorkflowNode(data.nodeId, 'running');
- appendWorkflowBroadcastChatLine(
- `▶ ${nodeType ? '[' + nodeType + '] ' : ''}${nodeTitle}`,
- 'font-size:11px;color:var(--text2);padding:2px 0;',
- `wf-bc-step-${data.nodeId}`
- );
- }
- if (data.type === 'wf_node_done') {
- forwardWorkflowEventToIframe('node_done', data);
- completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
- updateWorkflowNode(data.nodeId, 'done');
- // Update step line in chat
- const chatStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
- if (chatStep) {
- chatStep.style.color = 'var(--green)';
- chatStep.textContent = chatStep.textContent.replace('▶', '✓');
- }
- }
- if (data.type === 'wf_node_error') {
- forwardWorkflowEventToIframe('node_error', data);
- errorStepCard(data.nodeId, data.error || 'Unknown error', data.duration_ms);
- updateWorkflowNode(data.nodeId, 'error');
- // Update step line in chat
- const errStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
- if (errStep) {
- errStep.style.color = 'var(--red)';
- errStep.textContent = errStep.textContent.replace('▶', '✗') + ' — ' + (data.error || '');
- }
- }
- if (data.type === 'wf_node_skipped') {
- forwardWorkflowEventToIframe('node_skipped', data);
- addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
- updateWorkflowNode(data.nodeId, 'skipped');
- }
- if (data.type === 'wf_file_start') {
- addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
- appendWorkflowBroadcastChatLine(`📄 Writing ${data.path || '?'}`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_file_done') {
- addDetailEntry('file', `✓ Written: ${data.path || '?'}`, null, 'success', { depth: 1 });
- const runStep = getCurrentRunningStepID();
- if (runStep) addFileToStepCard(runStep, data.path || '?');
- appendWorkflowBroadcastChatLine(`✓ Wrote ${data.path || '?'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_llm_thinking') {
- appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
- }
- if (data.type === 'wf_llm_tool_use') {
- const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
- addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInput, 'info', { depth: 1 });
- updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
- appendWorkflowBroadcastChatLine(`🔧 Tool: ${data.name || 'unknown'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_llm_tool_result') {
- const isErr = data.is_error || false;
- addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} Result`, data.content || null, isErr ? 'error' : 'success', { depth: 1 });
- appendWorkflowBroadcastChatLine(
- `${isErr ? '✗' : '✓'} Tool result`,
- `font-size:10px;color:${isErr ? 'var(--red)' : 'var(--green)'};padding:1px 0 1px 14px;`
- );
- }
- if (data.type === 'wf_tool_start') {
- forwardWorkflowEventToIframe('tool_start', data);
- const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
- addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
- appendWorkflowBroadcastChatLine(`🛠 Tool step: ${data.name || 'tool'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_tool_done') {
- forwardWorkflowEventToIframe('tool_done', data);
- const toolOutput = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
- addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
- appendWorkflowBroadcastChatLine(`✓ Tool step done: ${data.name || 'tool'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_tool_error') {
- forwardWorkflowEventToIframe('tool_error', data);
- addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
- appendWorkflowBroadcastChatLine(
- `${data.allowError ? '⚠' : '✗'} Tool step error: ${data.name || 'tool'}`,
- `font-size:10px;color:${data.allowError ? 'var(--orange)' : 'var(--red)'};padding:1px 0 1px 14px;`
- );
- }
- if (data.type === 'wf_tool_message') {
- forwardWorkflowEventToIframe('tool_message', data);
- const toolDetail = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
- addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetail, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
- appendWorkflowBroadcastChatLine(
- `• ${data.name || 'tool'}: ${data.message || ''}`,
- `font-size:10px;color:${data.level === 'error' ? 'var(--red)' : data.level === 'warn' ? 'var(--orange)' : 'var(--text2)'};padding:1px 0 1px 14px;`
- );
- }
- if (data.type === 'wf_llm_done') {
- flushStreamBoxes();
- const mdl = data.model || '';
- const usg = data.usage || {};
- const parts = [mdl, usg.input_tokens ? `in:${usg.input_tokens}` : '', usg.output_tokens ? `out:${usg.output_tokens}` : '', data.latency_ms ? `${(data.latency_ms / 1000).toFixed(1)}s` : ''].filter(Boolean).join(' | ');
- addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
- appendWorkflowBroadcastChatLine(`✓ LLM done${parts ? ' — ' + parts : ''}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_llm_error') {
- addDetailEntry('llm', `✗ LLM Error: ${data.error || 'unknown'}`, data, 'error');
- appendWorkflowBroadcastChatLine(`✗ LLM error: ${data.error || 'unknown'}`, 'font-size:10px;color:var(--red);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_var_changed') {
- const vn = data.name || '?';
- const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 80) : '—';
- const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 80) : '—';
- addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
- appendWorkflowBroadcastChatLine(`📊 ${vn} updated`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_text') {
- addDetailEntry('workflow', data.text || '', null, 'info', { depth: 1 });
- if (data.text) appendWorkflowBroadcastChatLine(data.text, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
- }
- if (data.type === 'wf_token') {
- // Streaming LLM tokens from VLGenerate-initiated workflows (broadcast path)
- appendToStreamBox('wf-response-broadcast', '💬 Response', data.token || '');
- }
- if (data.type === 'wf_checkpoint') {
- _lastRunCheckpoint = data.checkpoint || data;
- addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} done)`, null, 'info', { depth: 1 });
- // Forward checkpoint to workflow-editor iframe for re-run support
- sendToWorkflowIframe({ type: 'setCheckpoint', checkpoint: data.checkpoint || data, runID: data.runID, clientRunToken: data.clientRunToken || null });
- }
- if (data.type === 'wf_done') {
- forwardWorkflowEventToIframe('workflow_done', data);
- _workflowActive = false;
- window._skipFlowAutoLoad = false;
- try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
- flushStreamBoxes();
- setChatStatusRunning(false);
- setStatus(`Workflow done: ${data.workflowName || ''}`, 'green');
- addDetailEntry('workflow', `✅ Workflow completed: ${data.workflowName || ''}`, data, 'success');
- _setMapIndicator(true);
- // Show completion in main chat
- if (window._wfBroadcastChatEl) {
- const doneDiv = document.createElement('div');
- doneDiv.style.cssText = 'font-size:11px;color:var(--green);padding:4px 0;font-weight:600;';
- doneDiv.textContent = `✓ Workflow completed. ${data.filesWritten?.length || 0} files written.`;
- window._wfBroadcastChatEl.querySelector('.content-text').appendChild(doneDiv);
- scrollChat();
- window._wfBroadcastChatEl = null;
- } else {
- addMsg('assistant', `**Workflow completed.** ${data.filesWritten?.length || 0} files written.`);
- }
- loadFileTree();
- }
- if (data.type === 'wf_error') {
- forwardWorkflowEventToIframe('workflow_failed', data);
- _workflowActive = false;
- window._skipFlowAutoLoad = false;
- try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
- flushStreamBoxes();
- setChatStatusRunning(false);
- setStatus(`Workflow error: ${data.error || ''}`, 'red');
- addDetailEntry('workflow', `❌ Workflow failed: ${data.workflowName || ''} — ${data.error || ''}`, null, 'error');
- // Show error in main chat
- if (window._wfBroadcastChatEl) {
- const errDiv = document.createElement('div');
- errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:4px 0;font-weight:600;';
- errDiv.textContent = `✗ Workflow error: ${data.error || 'Unknown'}`;
- window._wfBroadcastChatEl.querySelector('.content-text').appendChild(errDiv);
- scrollChat();
- window._wfBroadcastChatEl = null;
- } else {
- addMsg('assistant', `**Workflow error:** ${data.error || 'Unknown'}`);
- }
- }
- // Metadata ready — show green dot on Map tab instead of stealing focus
- if (data.type === 'metadata_ready' && data.meta) {
- _setMapIndicator(true);
- openMetadataTab(data.meta);
- }
- // ── AutoTest SSE events ──
- // PRINCIPLE: Chat shows only high-level milestones (phase start/done, app workflows, case results, summary)
- // Detail panel shows ALL granular info (server logs, LLM data, selectors, timing, node progress)
- if (data.type === 'autotest_progress') {
- const { phase, status, message } = data;
- setStatus(`AutoTest: ${message}`, status === 'error' ? 'red' : status === 'done' ? 'green' : 'yellow');
- if (status === 'running') {
- $('chatStatusBar').style.display = 'flex';
- if (!_chatStartTime) { _chatStartTime = Date.now(); _chatElapsedTimer = setInterval(updateChatElapsed, 1000); }
- if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
- _autotestChatBlock = null;
- window._wfEngineBox = null; // Reset WF engine aggregation box
- window._wfEngineTokens = 0;
- window._wfEngineEvents = 0;
- }
- updateChatStatusBar(status === 'running' ? `Testing ${phase}...` : `Test ${phase} ${status}`, message);
- if (status === 'done' || status === 'error') { if (phase === 'run') setChatStatusRunning(false); }
- // Detail panel: full progress log
- addDetailEntry('autotest', `[${phase}] ${message}`, null, status === 'error' ? 'error' : status === 'done' ? 'success' : 'info');
- // Chat: only show phase milestones (start/done), not every intermediate step
- if (status === 'done' || status === 'error' || (status === 'running' && (phase === 'generate' || phase === 'run'))) {
- const icon = status === 'done' ? '>' : status === 'error' ? 'x' : '...';
- _ensureAutotestChatBlock();
- const stepEl = document.createElement('div');
- stepEl.style.cssText = `font-size:11px;padding:2px 0;color:${status === 'error' ? 'var(--red)' : status === 'done' ? 'var(--green)' : 'var(--text2)'};${status === 'done' || status === 'error' ? 'font-weight:600;' : ''}`;
- stepEl.textContent = `${icon} [${phase}] ${message}`;
- _autotestChatBlock.appendChild(stepEl);
- scrollChat();
- }
- if (status === 'running') {
- _atPipelineStatus = 'running';
- if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
- }
- if (status === 'done' && phase === 'run') _atPipelineStatus = 'done';
- if (status === 'error') _atPipelineStatus = 'error';
- if (phase === 'run' && status === 'running') _atApps.forEach(a => { if (a.status !== 'done' && a.status !== 'error') a.status = 'running'; });
- updateAtWfList();
- }
- // autotest_detail → Detail panel ONLY (server logs, LLM responses, selector info, timing)
- if (data.type === 'autotest_detail') {
- const dType = data.detailType === 'warn' ? 'warn' : data.detailType === 'success' ? 'success' : data.detailType === 'error' ? 'error' : 'info';
- const detailData = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
- addDetailEntry('autotest', data.message || '', detailData, dType, { depth: data.phase === 'run' ? 2 : 1 });
- }
- // autotest_case_done → Both panels (it's a milestone)
- if (data.type === 'autotest_case_done') {
- const { caseId, status, current, total } = data;
- const icon = status === 'passed' ? '>' : status === 'soft_pass' ? '~' : 'x';
- const dotStatus = status === 'passed' ? 'done' : status === 'soft_pass' ? 'skipped' : 'error';
- setStatus(`AutoTest: ${current}/${total} ${caseId}`, status === 'passed' ? 'green' : 'red');
- // Detail panel: case result
- addDetailEntry('autotest', `${icon} ${current}/${total} ${caseId} [${status}]`, null, dotStatus === 'done' ? 'success' : 'error', { depth: 1 });
- setAtCaseStatus(caseId, dotStatus);
- // Chat: case result (milestone — not a duplicate since detail shows step-level)
- _ensureAutotestChatBlock();
- const caseEl = document.createElement('div');
- caseEl.style.cssText = `font-size:10px;padding:1px 0 1px 12px;color:${dotStatus === 'done' ? 'var(--green)' : dotStatus === 'skipped' ? '#cc0' : 'var(--red)'};`;
- caseEl.textContent = `${icon} ${current}/${total} ${caseId} [${status}]`;
- _autotestChatBlock.appendChild(caseEl);
- scrollChat();
- }
- // autotest_workflow_saved → Both panels (workflow creation is a milestone)
- if (data.type === 'autotest_workflow_saved') {
- const { name, appId, caseCount, level, cases } = data;
- if (level === 'pipeline') {
- _atPipelineStatus = 'idle';
- addDetailEntry('autotest', `Pipeline workflow saved: ${name}`, null, 'success');
- loadWorkflowIntoFlowTab('autotest-pipeline');
- } else if (level === 'app') {
- const existing = _atApps.find(a => a.appId === appId);
- if (existing) { existing.caseCount = caseCount; existing.name = name; if (cases) existing.cases = cases.map(c => ({ ...c, status: 'idle' })); }
- else _atApps.push({ name, appId, caseCount, status: 'idle', cases: (cases || []).map(c => ({ ...c, status: 'idle' })) });
- if (!_atActiveApp && _atApps.length > 0) _atActiveApp = _atApps[0].appId;
- // Detail: full case list
- addDetailEntry('autotest', `App workflow: ${name} (${caseCount} cases)`, cases ? JSON.stringify(cases.map(c => `${c.id}: ${c.name} [${c.priority}] (${c.stepsCount} steps)`), null, 2) : null, 'success', { depth: 1 });
- // Chat: just workflow name
- _ensureAutotestChatBlock();
- const wfEl = document.createElement('div');
- wfEl.style.cssText = 'font-size:10px;padding:1px 0 1px 12px;color:var(--accent);';
- wfEl.textContent = `+ Workflow: ${name} (${caseCount} test cases)`;
- _autotestChatBlock.appendChild(wfEl);
- scrollChat();
- } else if (level === 'testcase') {
- // Detail only: per-testcase workflow (granular log)
- addDetailEntry('autotest', `Test workflow: ${data.caseName || data.caseId} (${data.stepsCount} steps)`, null, 'info', { depth: 2 });
- }
- updateAtWfList();
- if (currentFlowTab === 'autotest') populateFlowWorkflowSelect();
- }
- // autotest_run_complete → Both panels (final summary)
- if (data.type === 'autotest_run_complete') {
- const { passed, failed, softPassed, total, failures } = data;
- for (const app of _atApps) {
- const hasError = (app.cases || []).some(c => c.status === 'error');
- app.status = hasError ? 'error' : 'done';
- }
- _atPipelineStatus = (failed > 0 || (softPassed || 0) > 0) ? 'error' : 'done';
- updateAtWfList();
- // Detail: full evaluation data
- addDetailEntry('autotest', `Run complete: ${passed} passed, ${failed} failed, ${softPassed || 0} soft-passed / ${total} total`,
- failures?.length ? JSON.stringify(failures.map(f => ({ case: f.caseId, reason: f.reason, softPass: f.softPass })), null, 2) : null,
- failed > 0 ? 'error' : 'success');
- // Chat: summary line
- _ensureAutotestChatBlock();
- const sumEl = document.createElement('div');
- sumEl.style.cssText = 'font-size:11px;font-weight:600;padding:4px 0 0;border-top:1px solid var(--border);margin-top:4px;';
- sumEl.innerHTML = `<span style="color:var(--green)">${passed} passed</span> / <span style="color:var(--red)">${failed} failed</span>${softPassed > 0 ? ` / <span style="color:#cc0">${softPassed} soft</span>` : ''} / ${total} total`;
- _autotestChatBlock.appendChild(sumEl);
- scrollChat();
- if (failed > 0 || (softPassed || 0) > 0) showAutotestResultDialog(passed, failed, softPassed || 0, total, failures || []);
- }
- // WF Engine events → Detail panel ONLY — aggregate into a collapsible summary box
- if (data.type === 'autotest_workflow_event') {
- if (!window._wfEngineBox) {
- window._wfEngineTokens = 0;
- window._wfEngineEvents = 0;
- const box = document.createElement('div');
- box.className = 'detail-entry info depth-2';
- box.innerHTML = `
- <span class="de-time">${new Date().toLocaleTimeString()}</span>
- <span class="de-phase">[wf-engine]</span>
- <div class="de-msg" style="cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
- WF Engine stream <span class="wfe-stats" style="color:var(--accent);font-weight:600">0 events, 0 tokens</span> (click to expand)
- </div>
- <div class="wfe-log" style="display:none;max-height:300px;overflow:auto;font-size:10px;padding:4px;background:var(--bg2);border-radius:4px;margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2)"></div>`;
- const body = $('detailBody');
- body.appendChild(box);
- body.scrollTop = body.scrollHeight;
- window._wfEngineBox = box;
- }
- const evt = data.event || data;
- const evtStr = typeof evt === 'string' ? evt : JSON.stringify(evt);
- window._wfEngineEvents++;
- window._wfEngineTokens += Math.round(evtStr.length / 4); // rough token estimate
- const statsEl = window._wfEngineBox.querySelector('.wfe-stats');
- const tokK = (window._wfEngineTokens / 1000).toFixed(1);
- statsEl.textContent = `${window._wfEngineEvents} events, ~${tokK}K tokens`;
- const logEl = window._wfEngineBox.querySelector('.wfe-log');
- // Only keep last 50 events in DOM to prevent memory bloat
- const lines = logEl.children;
- if (lines.length > 50) logEl.removeChild(lines[0]);
- const line = document.createElement('div');
- line.style.cssText = 'border-bottom:1px solid var(--border);padding:1px 0;';
- // Show summary: node status or truncated content
- const nodeId = evt.nodeId || evt.node_id || '';
- const status = evt.status || evt.type || '';
- const content = evt.content || evt.text || '';
- if (nodeId || status) {
- line.textContent = `[${nodeId}] ${status}${content ? ': ' + content.slice(0, 120) : ''}`;
- } else {
- line.textContent = evtStr.slice(0, 200);
- }
- logEl.appendChild(line);
- }
- } catch {}
- });
- };
- // ===================== WORKFLOW ENGINE SSE (Dragon Broker) =====================
- // Connects to external workflow engine via Dragon Broker SSE endpoint.
- // Spec 3.16 §13.3 events + Engine v0.2.1 extensions.
- // Event order: step_start → llm_thinking → llm_token → llm_tool_use →
- // llm_tool_result → [循环] → llm_done → var_changed → file_done → step_done
- let _wfRunSSE = null; // EventSource for active workflow run
- let _activeRunID = null; // Current workflow run ID
- let _wfRunChatBlock = null; // Chat block for workflow milestones
- let _wfPauseToken = null; // waitToken from pause_start (needed for resume)
- const BROKER_BASE = 'http://localhost:9160';
- function connectWorkflowSSE(runID) {
- disconnectWorkflowSSE();
- _activeRunID = runID;
- _wfRunChatBlock = null;
- clearStreamBoxes();
- const es = new EventSource(`${BROKER_BASE}/workflow/${runID}/events`);
- _wfRunSSE = es;
- // Show status bar
- setChatStatusRunning(true);
- updateChatStatusBar('Workflow starting...', runID);
- addDetailEntry('workflow', `Connected to workflow ${runID}`, null, 'info');
- es.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data);
- _handleWorkflowEngineEvent(data);
- } catch {}
- };
- es.onerror = () => {
- // SSE auto-reconnects; only log once
- if (es.readyState === EventSource.CLOSED) {
- addDetailEntry('workflow', `SSE connection closed for ${runID}`, null, 'warn');
- }
- };
- }
- function disconnectWorkflowSSE() {
- if (_wfRunSSE) {
- try { _wfRunSSE.close(); } catch {}
- _wfRunSSE = null;
- }
- _activeRunID = null;
- _wfRunChatBlock = null;
- }
- function _ensureWfChatBlock() {
- if (_wfRunChatBlock) return;
- const container = $('chatMessages');
- const block = document.createElement('div');
- block.className = 'msg assistant';
- block.style.position = 'relative';
- const now = formatMsgTime(new Date());
- block.innerHTML = `<div class="label">workflow <span class="msg-time">${now}</span></div><div class="wf-chat-body" style="font-size:11px;line-height:1.6;"></div>`;
- container.appendChild(block);
- _wfRunChatBlock = block.querySelector('.wf-chat-body');
- scrollChat();
- }
- function _handleWorkflowEngineEvent(raw) {
- // SSE format per Spec §13.2: { run_id, seq, ts, type, step_id, payload }
- // Normalize: payload fields may be top-level (Engine) or nested in payload (Spec)
- const payload = raw.payload || raw;
- const type = raw.type || raw.event;
- const stepID = raw.stepID || raw.step_id || payload.stepId || payload.nodeId || null;
- // ── workflow_start → Status Bar + Detail Log + Chat ──
- if (type === 'workflow_start') {
- const name = payload.name || '';
- _lastWorkflowName = name;
- _lastRunCheckpoint = null;
- for (const k in _stepCards) delete _stepCards[k];
- addDetailEntry('workflow', `▶ Workflow started${name ? ': ' + name : ''}${payload.resumedFrom ? ' (resumed from ' + payload.resumedFrom + ')' : ''}`, payload.params || null, 'info');
- updateChatStatusBar(`Running: ${name || _activeRunID}`, '');
- _ensureWfChatBlock();
- const startEl = document.createElement('div');
- startEl.style.cssText = 'color:var(--accent);font-weight:600;padding:2px 0;';
- startEl.textContent = `▶ Workflow: ${name || _activeRunID}${payload.resumedFrom ? ' (from ' + payload.resumedFrom + ')' : ''}`;
- _wfRunChatBlock.appendChild(startEl);
- scrollChat();
- return;
- }
- // ── LLM thinking tokens → Detail Log (stream box, collapsible) ──
- if (type === 'llm_thinking') {
- appendToStreamBox(`wf-thinking-${stepID || _activeRunID}`, '💭 Thinking', payload.delta || payload.chunk || '');
- return;
- }
- // ── LLM response tokens → Detail Log (stream box) ──
- if (type === 'llm_token') {
- appendToStreamBox(`wf-response-${stepID || _activeRunID}`, '💬 Response', payload.delta || payload.chunk || '');
- return;
- }
- // ── LLM tool use → Detail Log (collapsible) ──
- if (type === 'llm_tool_use') {
- const toolName = payload.name || payload.tool_name || 'unknown';
- const toolInput = payload.input || payload.params || {};
- addDetailEntry('tool-call', `🔧 ${toolName}`, toolInput, 'info', { depth: 1 });
- updateChatStatusBar(`Tool: ${toolName}`, '');
- return;
- }
- // ── LLM tool result → Detail Log (collapsible) ──
- if (type === 'llm_tool_result') {
- const isError = payload.is_error || false;
- const content = payload.content || payload.result || '';
- const toolId = payload.tool_use_id || '';
- addDetailEntry('tool-result', `${isError ? '✗' : '✓'} Result${toolId ? ' [' + toolId.slice(-8) + ']' : ''}`, content, isError ? 'error' : 'success', { depth: 1 });
- return;
- }
- if (type === 'tool_start') {
- const toolName = payload.name || stepID || 'tool';
- addDetailEntry('tool-call', `🛠 ${toolName}`, payload.input || null, 'info', { depth: 1 });
- updateChatStatusBar(`Tool step: ${toolName}`, '');
- return;
- }
- if (type === 'tool_done') {
- const toolName = payload.name || stepID || 'tool';
- addDetailEntry('tool-result', `✓ ${toolName}`, payload.output || null, 'success', { depth: 1 });
- return;
- }
- if (type === 'tool_error') {
- const toolName = payload.name || stepID || 'tool';
- addDetailEntry('tool-result', `✗ ${toolName}${payload.allowError ? ' (continued)' : ''}`, payload.error || null, payload.allowError ? 'warn' : 'error', { depth: 1 });
- return;
- }
- if (type === 'tool_message') {
- const toolName = payload.name || stepID || 'tool';
- addDetailEntry('tool-call', `• ${toolName}: ${payload.message || ''}`, payload.data || null, payload.level === 'error' ? 'error' : payload.level === 'warn' ? 'warn' : 'info', { depth: 1 });
- return;
- }
- // ── LLM done → Detail Log (summary) ──
- if (type === 'llm_done') {
- flushStreamBoxes();
- const model = payload.model || '';
- const tokens = payload.usage || {};
- const latency = payload.latency_ms ? `${(payload.latency_ms / 1000).toFixed(1)}s` : '';
- const summary = [model, tokens.input_tokens ? `in:${tokens.input_tokens}` : '', tokens.output_tokens ? `out:${tokens.output_tokens}` : '', latency].filter(Boolean).join(' | ');
- addDetailEntry('llm', `✓ LLM complete — ${summary}`, null, 'success');
- return;
- }
- // ── LLM error → Main Chat + Detail Log ──
- if (type === 'llm_error') {
- const err = payload.error || {};
- const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Unknown LLM error';
- const retryable = (payload.retryable || err.retryable) ? ' (retryable)' : '';
- addDetailEntry('llm', `✗ LLM Error${retryable}: ${errMsg}`, payload, 'error');
- _ensureWfChatBlock();
- const errEl = document.createElement('div');
- errEl.style.cssText = 'color:var(--red);font-weight:600;padding:2px 0;';
- errEl.textContent = `✗ LLM Error${retryable}: ${errMsg}`;
- _wfRunChatBlock.appendChild(errEl);
- scrollChat();
- return;
- }
- // ── Variable changed → Detail Log ──
- if (type === 'var_changed') {
- const varName = payload.name || payload.variable || '?';
- const oldVal = payload.oldValue != null ? JSON.stringify(payload.oldValue).slice(0, 80) : (payload.old != null ? JSON.stringify(payload.old).slice(0, 80) : '—');
- const newVal = payload.newValue != null ? JSON.stringify(payload.newValue).slice(0, 80) : (payload.new != null ? JSON.stringify(payload.new).slice(0, 80) : '—');
- addDetailEntry('var', `📊 ${varName}: ${oldVal} → ${newVal}`, payload, 'info', { depth: 1 });
- return;
- }
- // ── Step start → Status Bar + Step Card + Chat + DAG ──
- if (type === 'step_start') {
- const stepType = payload.type || payload.step_type || payload.stepType || '';
- const stepTitle = payload.meta?.title || stepID || '?';
- // Use enhanced step card
- addStepCard(stepID, stepType, stepTitle, payload.resolvedInputs);
- updateChatStatusBar(`Running ${stepTitle}...`, stepType);
- updateWorkflowNode(stepID, 'running');
- clearStreamBoxes();
- // Chat: show step progress
- _ensureWfChatBlock();
- const stepEl = document.createElement('div');
- stepEl.id = `wf-step-${stepID}`;
- stepEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text2);';
- stepEl.textContent = ` ▶ ${stepTitle}${stepType ? ' [' + stepType + ']' : ''}`;
- _wfRunChatBlock.appendChild(stepEl);
- scrollChat();
- return;
- }
- // ── Step done → Step Card + Chat + DAG ──
- if (type === 'step_done') {
- flushStreamBoxes();
- const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
- // Complete step card with outputs
- completeStepCard(stepID, payload.outputs, payload.selected, payload.duration_ms);
- updateWorkflowNode(stepID, 'done');
- // Chat: update step line to show done
- const chatStepEl = document.getElementById(`wf-step-${stepID}`);
- if (chatStepEl) {
- chatStepEl.style.color = 'var(--green)';
- chatStepEl.textContent = ` ✓ ${stepID || '?'} done${duration}`;
- }
- return;
- }
- // ── Step error → Step Card + Main Chat + DAG ──
- if (type === 'step_error') {
- flushStreamBoxes();
- const err = payload.error || {};
- const errMsg = (typeof err === 'string' ? err : err.message) || 'Step error';
- // Error step card with re-run button
- errorStepCard(stepID, errMsg, payload.duration_ms);
- updateWorkflowNode(stepID, 'error');
- // Show in chat — step errors are actionable
- _ensureWfChatBlock();
- const errEl = document.createElement('div');
- errEl.style.cssText = 'color:var(--red);padding:2px 0;';
- errEl.textContent = `✗ Step ${stepID || '?'}: ${errMsg}`;
- _wfRunChatBlock.appendChild(errEl);
- scrollChat();
- return;
- }
- // ── Step skipped → Detail Log + DAG ──
- if (type === 'step_skipped') {
- const reason = payload.reason || 'if_false';
- addDetailEntry('step', `⊘ ${stepID || '?'} skipped (${reason})`, null, 'info', { depth: 1 });
- updateWorkflowNode(stepID, 'skipped');
- return;
- }
- // ── Step print → Detail Log + Chat ──
- if (type === 'step_print') {
- const msg = payload.message || payload.value || '';
- addDetailEntry('print', `📝 ${stepID || ''}: ${msg}`, null, 'info', { depth: 1 });
- // Print messages are user-facing — show in chat
- _ensureWfChatBlock();
- const printEl = document.createElement('div');
- printEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text);';
- printEl.textContent = ` 📝 ${msg}`;
- _wfRunChatBlock.appendChild(printEl);
- scrollChat();
- return;
- }
- // ── File start → Detail Log ──
- if (type === 'file_start') {
- const path = payload.path || '?';
- addDetailEntry('file', `📄 Writing: ${path}`, null, 'info', { depth: 1 });
- return;
- }
- // ── File done → Detail Log + Step Card + Chat + File Tree ──
- if (type === 'file_done') {
- const filePath = payload.path || '?';
- const size = payload.size_bytes != null ? ` (${payload.size_bytes > 1024 ? (payload.size_bytes / 1024).toFixed(1) + 'KB' : payload.size_bytes + 'B'})` : '';
- addDetailEntry('file', `✓ Written: ${filePath}${size}`, null, 'success', { depth: 1 });
- // Associate with running step card
- const runStep = getCurrentRunningStepID();
- if (runStep) addFileToStepCard(runStep, filePath);
- // Trigger file tree refresh
- if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
- window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
- // Chat: show file written
- _ensureWfChatBlock();
- const fileEl = document.createElement('div');
- fileEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--green);';
- fileEl.textContent = ` 📄 ${filePath}${size}`;
- _wfRunChatBlock.appendChild(fileEl);
- scrollChat();
- return;
- }
- // ── Pause start → Main Chat (approval buttons) ──
- if (type === 'pause_start') {
- const reason = payload.reason || 'Workflow paused — awaiting approval';
- _wfPauseToken = payload.waitToken || null;
- addDetailEntry('pause', `⏸ Paused: ${reason}`, payload, 'warn');
- updateChatStatusBar('Paused', reason);
- _ensureWfChatBlock();
- const pauseDiv = document.createElement('div');
- pauseDiv.className = 'wf-pause-block';
- pauseDiv.style.cssText = 'padding:6px 0;border-top:1px solid var(--border);margin-top:4px;';
- pauseDiv.innerHTML = `
- <div style="color:var(--orange);font-weight:600;margin-bottom:4px;">⏸ ${escapeHtml(reason)}</div>
- <div style="display:flex;gap:6px;">
- <button onclick="resumeBrokerWorkflow()" style="background:var(--green);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">▶ Resume</button>
- <button onclick="abortWorkflow()" style="background:var(--red);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">✗ Abort</button>
- </div>`;
- _wfRunChatBlock.appendChild(pauseDiv);
- scrollChat();
- return;
- }
- // ── Pause resumed → Detail Log + update chat ──
- if (type === 'pause_resumed') {
- _wfPauseToken = null;
- addDetailEntry('pause', `▶ Resumed (req: ${payload.requestId || '—'})`, null, 'success');
- updateChatStatusBar('Resumed', '');
- // Disable pause buttons
- const btns = document.querySelectorAll('.wf-pause-block button');
- btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
- return;
- }
- // ── Pause timeout → Detail Log + Main Chat ──
- if (type === 'pause_timeout') {
- _wfPauseToken = null;
- const action = payload.timeoutAction || '';
- addDetailEntry('pause', `⏰ Pause timed out → ${action}`, payload, 'warn');
- _ensureWfChatBlock();
- const toEl = document.createElement('div');
- toEl.style.cssText = 'color:var(--orange);padding:2px 0;';
- toEl.textContent = `⏰ Pause timed out${action ? ' → ' + action : ''}`;
- _wfRunChatBlock.appendChild(toEl);
- scrollChat();
- // Disable pause buttons
- const btns = document.querySelectorAll('.wf-pause-block button');
- btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
- return;
- }
- // ── Pause rejected → Detail Log ──
- if (type === 'pause_rejected') {
- const reasonCode = payload.reasonCode || 'unknown';
- addDetailEntry('pause', `✗ Resume rejected: ${reasonCode}`, payload, 'error');
- return;
- }
- // ── Workflow done → Main Chat + Status Bar ──
- if (type === 'workflow_done') {
- flushStreamBoxes();
- const stopId = payload.stop_id || '';
- const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
- const summary = `Workflow completed${duration}${stopId ? ' at ' + stopId : ''}`;
- addDetailEntry('workflow', `✓ ${summary}`, payload, 'success');
- setChatStatusRunning(false);
- setStatus('Workflow done', 'green');
- _ensureWfChatBlock();
- const doneEl = document.createElement('div');
- doneEl.style.cssText = 'color:var(--green);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
- doneEl.textContent = `✓ ${summary}`;
- _wfRunChatBlock.appendChild(doneEl);
- scrollChat();
- disconnectWorkflowSSE();
- return;
- }
- // ── Workflow failed → Main Chat + Status Bar ──
- if (type === 'workflow_failed') {
- flushStreamBoxes();
- const err = payload.error || {};
- const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Workflow failed';
- const failedStep = payload.failed_step_id || '';
- const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
- addDetailEntry('workflow', `✗ ${errMsg}${failedStep ? ' at ' + failedStep : ''}${duration}`, payload, 'error');
- setChatStatusRunning(false);
- setStatus('Workflow failed', 'red');
- _ensureWfChatBlock();
- const failEl = document.createElement('div');
- failEl.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
- failEl.textContent = `✗ ${errMsg}${failedStep ? ' (step: ' + failedStep + ')' : ''}`;
- _wfRunChatBlock.appendChild(failEl);
- scrollChat();
- disconnectWorkflowSSE();
- return;
- }
- // ── Workflow cancelled → Main Chat + Status Bar ──
- if (type === 'workflow_cancelled') {
- flushStreamBoxes();
- const reason = payload.reason || 'cancelled';
- const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
- addDetailEntry('workflow', `⊘ Cancelled: ${reason}${duration}`, payload, 'warn');
- setChatStatusRunning(false);
- setStatus('Workflow cancelled', 'orange');
- _ensureWfChatBlock();
- const cancelEl = document.createElement('div');
- cancelEl.style.cssText = 'color:var(--orange);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
- cancelEl.textContent = `⊘ Workflow cancelled: ${reason}`;
- _wfRunChatBlock.appendChild(cancelEl);
- scrollChat();
- disconnectWorkflowSSE();
- return;
- }
- }
- // ── Workflow control actions ──
- // Resume per Spec §11.4: { runId, token, payload, requestId }
- async function resumeBrokerWorkflow(resumePayload) {
- const runID = _activeRunID;
- if (!runID) return;
- try {
- const body = {
- runId: runID,
- token: _wfPauseToken || '',
- payload: resumePayload || {},
- requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
- };
- const res = await fetch(`${BROKER_BASE}/workflow/${runID}/resume`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- addDetailEntry('workflow', `▶ Resume requested (req: ${body.requestId})`, null, 'info');
- updateChatStatusBar('Resuming...', '');
- } catch (e) {
- addDetailEntry('workflow', `Resume failed: ${e.message}`, null, 'error');
- }
- }
- async function abortWorkflow() {
- const runID = _activeRunID;
- if (!runID) return;
- try {
- const res = await fetch(`${BROKER_BASE}/workflow/${runID}/abort`, { method: 'POST' });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- addDetailEntry('workflow', '✗ Abort requested', null, 'warn');
- updateChatStatusBar('Aborting...', '');
- } catch (e) {
- addDetailEntry('workflow', `Abort failed: ${e.message}`, null, 'error');
- }
- }
- async function fetchWorkflowSnapshot(runID) {
- try {
- const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/status`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return await res.json();
- } catch (e) {
- addDetailEntry('workflow', `Snapshot fetch failed: ${e.message}`, null, 'error');
- return null;
- }
- }
- async function fetchWorkflowVariables(runID) {
- try {
- const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/variables`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return await res.json();
- } catch (e) {
- return null;
- }
- }
- // ===================== WORKFLOW RE-RUN DIALOG =====================
- async function openRerunDialog(stepID) {
- // Fetch checkpoint — try local API first, then broker
- let checkpoint = null;
- try {
- // Try local API (for workflows run through /api/workflow/execute)
- const localRes = await fetch('/api/workflow/variables');
- if (localRes.ok) {
- // Use the stored checkpoint from last run
- const cpRes = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
- if (cpRes.ok) checkpoint = await cpRes.json();
- }
- } catch {}
- if (!checkpoint && _activeRunID) {
- try {
- const res = await fetch(`${BROKER_BASE}/workflow/${_activeRunID}/checkpoint`);
- if (res.ok) checkpoint = await res.json();
- } catch {}
- }
- if (!checkpoint && _lastRunCheckpoint) {
- // Use the last checkpoint received via SSE
- checkpoint = _lastRunCheckpoint;
- }
- // Build the dialog
- const vars = checkpoint?.variables || {};
- let varRows = '';
- for (const [k, v] of Object.entries(vars)) {
- const valStr = typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '');
- const isLong = valStr.length > 200;
- varRows += `
- <div class="rr-var-row">
- <div class="rr-var-name">${escapeHtml(k)}</div>
- <textarea class="rr-var-val" data-var="${escapeHtml(k)}" rows="${isLong ? 6 : 2}">${escapeHtml(isLong ? valStr.substring(0, 2000) : valStr)}</textarea>
- </div>`;
- }
- const dialog = document.createElement('div');
- dialog.className = 'modal-overlay open';
- dialog.id = 'rerunDialog';
- dialog.onclick = (e) => { if (e.target === dialog) dialog.remove(); };
- dialog.innerHTML = `
- <div class="modal-box" style="max-width:600px;max-height:80vh;overflow-y:auto;">
- <h3 style="margin:0 0 12px;font-size:14px;color:var(--accent);">🔄 Re-run from: ${escapeHtml(stepID)}</h3>
- <div style="font-size:10px;color:var(--text2);margin-bottom:8px;">
- Workflow: ${escapeHtml(_lastWorkflowName || 'unknown')}<br>
- Steps before this one will NOT re-execute.<br>
- You can edit variables below before re-running.
- </div>
- <div style="font-size:11px;font-weight:600;margin:8px 0 4px;color:var(--text);">Pipeline Variables:</div>
- <div class="rr-vars" style="max-height:300px;overflow-y:auto;">
- ${varRows || '<div style="color:var(--text2);font-size:10px;">No variables available</div>'}
- </div>
- <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;">
- <button onclick="document.getElementById('rerunDialog').remove()" style="background:none;border:1px solid var(--border);color:var(--text2);padding:6px 16px;border-radius:4px;cursor:pointer;">Cancel</button>
- <button onclick="executeRerun('${escapeHtml(stepID)}')" style="background:var(--accent);color:var(--bg);border:none;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600;">🚀 Re-run</button>
- </div>
- </div>`;
- document.body.appendChild(dialog);
- }
- async function executeRerun(stepID) {
- const dialog = document.getElementById('rerunDialog');
- if (!dialog) return;
- // Collect overrides from edited variables
- const overrides = {};
- const textareas = dialog.querySelectorAll('.rr-var-val');
- for (const ta of textareas) {
- const varName = ta.dataset.var;
- const val = ta.value.trim();
- try {
- // Try to parse as JSON
- overrides[varName] = JSON.parse(val);
- } catch {
- overrides[varName] = val;
- }
- }
- dialog.remove();
- // Fetch checkpoint
- let checkpoint = _lastRunCheckpoint;
- if (!checkpoint) {
- try {
- const res = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
- if (res.ok) checkpoint = await res.json();
- } catch {}
- }
- if (!checkpoint) {
- addDetailEntry('workflow', '✗ Cannot re-run: no checkpoint available', null, 'error');
- return;
- }
- addDetailEntry('workflow', `🔄 Re-running from ${stepID} with ${Object.keys(overrides).length} variable(s)`, null, 'info');
- // Clear step cards for fresh display
- for (const k in _stepCards) delete _stepCards[k];
- // Call rerun API (SSE stream)
- try {
- const response = await fetch('/api/workflow/rerun', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- workflowName: _lastWorkflowName,
- checkpoint,
- stepID,
- overrides,
- }),
- });
- if (!response.body) {
- addDetailEntry('workflow', '✗ Re-run failed: no response body', null, 'error');
- return;
- }
- // Process SSE stream (same as sendMessage workflow handling)
- setChatStatusRunning(true);
- updateChatStatusBar(`Re-running from ${stepID}...`, '');
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- let currentEvent = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() || '';
- for (const line of lines) {
- if (line.startsWith('event: ')) {
- currentEvent = line.slice(7).trim();
- } else if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6));
- // Dispatch to the same workflow event handler
- const raw = { type: currentEvent, payload: data, stepID: data.stepId || data.nodeId };
- _handleWorkflowEngineEvent(raw);
- } catch {}
- }
- }
- }
- setChatStatusRunning(false);
- } catch (e) {
- addDetailEntry('workflow', `✗ Re-run failed: ${e.message}`, null, 'error');
- setChatStatusRunning(false);
- }
- }
- // ===================== SYNTAX HIGHLIGHTING =====================
- function highlightCode(code, ext) {
- const lines = code.split('\n');
- const isVL = ['vx','sc','cp','vs','vdb','vth'].includes(ext);
- const isJson = ext === 'json';
- return lines.map(line => {
- let html = escapeHtml(line);
- if (isVL) {
- html = highlightVL(html);
- } else if (isJson) {
- html = highlightJSON(html);
- }
- return `<span class="line">${html}</span>`;
- }).join('\n');
- }
- function highlightVL(line) {
- // Comments
- if (/^\s*\/\//.test(line)) return `<span class="cmt">${line}</span>`;
- // Keywords
- line = line.replace(/\b(SECTION|SERVICE|PUBLIC_SERVICE|EVENT|HANDLER|RETURN|IF|ELSE|FOR|EACH|SET|LET|TRUE|FALSE|NULL|LAYOUT|STYLE|NAVIGATION|DATA|METHODS|COMPUTED|INIT|MOUNTED|WATCH|COMPONENT|PROPS|EMIT|SLOT|ACTION|ASYNC|AWAIT|TRY|CATCH)\b/g, '<span class="kw">$1</span>');
- // Tags like <Section-X> <Component-Y> <ServiceDomain-Z>
- line = line.replace(/(<)([\w-]+)(\/?\s*>|[^&]*>)/g, '<span class="tag">$1$2$3</span>');
- // $variables
- line = line.replace(/(\$\w+)/g, '<span class="var">$1</span>');
- // @events
- line = line.replace(/(@\w+)/g, '<span class="evt">$1</span>');
- // Strings
- line = line.replace(/("[^&]*"|'[^&]*'|"[^"]*"|'[^']*')/g, '<span class="str">$1</span>');
- // Numbers
- line = line.replace(/\b(\d+\.?\d*)\b/g, '<span class="num">$1</span>');
- return line;
- }
- function highlightJSON(line) {
- // Property keys
- line = line.replace(/(")([^&]+)(")\s*:/g, '<span class="prop">$1$2$3</span>:');
- // String values
- line = line.replace(/:\s*(")([^&]*)(")/g, ': <span class="str">$1$2$3</span>');
- // Numbers
- line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="num">$1</span>');
- // Booleans / null
- line = line.replace(/:\s*(true|false|null)\b/g, ': <span class="kw">$1</span>');
- return line;
- }
- // renderMarkdown is defined earlier in the file (search for "Simple markdown → HTML renderer")
- // ===================== FLOW EDITOR TOOLBAR =====================
- let currentFlowTab = 'generate'; // 'generate' | 'adjust' | 'autotest'
- // ── AutoTest 3-layer state ──
- let _atApps = []; // [{name, appId, caseCount, status, cases:[{id,name,status}]}]
- let _atPipelineStatus = 'idle';
- let _atActiveLevel = 'pipeline';
- let _atActiveApp = '';
- let _atActiveCase = '';
- /** Load existing autotest app workflows from disk on init */
- async function loadAtAppsFromWorkflows() {
- try {
- // Try loading saved test cases first (has full case info)
- const casesRes = await fetch('/api/file/raw?path=.vl-code/autotest-cases.json');
- if (casesRes.ok) {
- const rawText = await casesRes.text();
- if (rawText) {
- const parsed = JSON.parse(rawText);
- const testCases = parsed.testCases || [];
- if (testCases.length > 0) {
- const appGroups = {};
- for (const tc of testCases) {
- const appId = tc.appId || 'App';
- if (!appGroups[appId]) appGroups[appId] = [];
- appGroups[appId].push({ id: tc.id, name: tc.name || tc.id, status: 'idle', priority: tc.priority, stepsCount: tc.steps?.length || 0 });
- }
- _atApps = [];
- for (const [appId, cases] of Object.entries(appGroups)) {
- const safeName = `autotest-${appId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
- _atApps.push({ name: safeName, appId, caseCount: cases.length, status: 'idle', cases });
- }
- if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
- // Restore test results (pass/fail status) from saved results
- await _restoreAtResults();
- updateAtWfList();
- return;
- }
- }
- }
- // Fallback: scan workflow files
- const data = await api('/api/workflows');
- const wfs = (data.workflows || []).filter(w => w.name.startsWith('autotest-') && !w.name.startsWith('autotest-tc-') && w.name !== 'autotest-pipeline');
- if (wfs.length === 0) return;
- _atApps = [];
- for (const wf of wfs) {
- const appId = wf.name.replace('autotest-', '');
- try {
- const wfData = await api(`/api/workflow/${wf.name}`);
- const steps = wfData.workflow?.steps || [];
- const cases = steps.filter(s => s.id && s.meta?.title).map(s => ({
- id: s.id, name: s.meta?.title || s.id, status: 'idle'
- }));
- _atApps.push({ name: wf.name, appId, caseCount: cases.length || wf.stepCount, status: 'idle', cases });
- } catch {
- _atApps.push({ name: wf.name, appId, caseCount: wf.stepCount, status: 'idle', cases: [] });
- }
- }
- if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
- await _restoreAtResults();
- updateAtWfList();
- } catch {}
- }
- /** Restore pass/fail status from saved autotest-results.json */
- async function _restoreAtResults() {
- try {
- const res = await fetch('/api/file/raw?path=.vl-code/autotest-results.json');
- if (!res.ok) return;
- const rawText = await res.text();
- if (!rawText) return;
- const results = JSON.parse(rawText);
- const evals = results.evaluations || [];
- if (evals.length === 0) return;
- // Map caseId → status
- const statusMap = {};
- for (const ev of evals) {
- const id = ev.caseId || ev.id;
- if (!id) continue;
- if (ev.evaluation?.pass) statusMap[id] = ev.evaluation?.softPass ? 'soft' : 'done';
- else statusMap[id] = 'error';
- }
- // Apply to _atApps
- let anyResult = false;
- for (const app of _atApps) {
- let appHasError = false, appAllDone = true;
- for (const tc of (app.cases || [])) {
- if (statusMap[tc.id]) { tc.status = statusMap[tc.id]; anyResult = true; }
- if (tc.status === 'error') appHasError = true;
- if (tc.status === 'idle') appAllDone = false;
- }
- if (anyResult) app.status = appHasError ? 'error' : appAllDone ? 'done' : 'idle';
- }
- if (anyResult) {
- const hasError = _atApps.some(a => a.status === 'error');
- _atPipelineStatus = hasError ? 'error' : 'done';
- }
- } catch {}
- }
- function switchFlowTab(tab) {
- currentFlowTab = tab;
- document.querySelectorAll('.flow-sub-tab').forEach(t => t.classList.toggle('active', t.dataset.flow === tab));
- populateFlowWorkflowSelect();
- updateFlowWfList();
- if (tab === 'autotest') {
- // Restore saved test cases from disk if not already loaded
- if (_atApps.length === 0) loadAtAppsFromWorkflows();
- updateAtWfList();
- loadActiveFlowWorkflow();
- } else {
- $('atWfList').classList.remove('visible');
- loadActiveFlowWorkflow();
- }
- }
- /** Render the workflow picker bar for Generate / Adjust sub-tabs */
- function updateFlowWfList() {
- const list = $('flowWfList');
- if (currentFlowTab === 'autotest') { list.classList.remove('visible'); return; }
- list.classList.add('visible');
- const workflows = currentFlowTab === 'generate' ? CODEGEN_WORKFLOWS : ADJUST_WORKFLOWS;
- const sel = $('flowWfSelect');
- const currentFile = sel.value || (currentFlowTab === 'generate'
- ? (workflowBindings.generate || 'parallel-codegen')
- : (workflowBindings.adjust || 'incremental-update'));
- list.innerHTML = '';
- for (const [key, info] of Object.entries(workflows)) {
- const isActive = currentFile === info.file || currentFile === key;
- const div = document.createElement('div');
- div.className = 'flow-wf-item' + (isActive ? ' active' : '');
- div.innerHTML = `<span class="fwi-name">${escapeHtml(info.label)}</span><span class="fwi-desc">${escapeHtml(info.desc)}</span>`;
- div.onclick = () => {
- _setFlowWfSelectOrStore(info.file, sel);
- loadFlowWorkflow(info.file);
- if (currentFlowTab === 'generate') selectCodegenWorkflow(key);
- list.querySelectorAll('.flow-wf-item').forEach(el => el.classList.remove('active'));
- div.classList.add('active');
- };
- list.appendChild(div);
- }
- }
- async function populateFlowWorkflowSelect() {
- const sel = $('flowWfSelect');
- sel.innerHTML = '<option value="">-- Select Workflow --</option>';
- try {
- const data = await api('/api/workflows');
- for (const wf of (data.workflows || [])) {
- const isAutotest = wf.name.startsWith('autotest-');
- if (currentFlowTab === 'autotest' && !isAutotest) continue;
- if (currentFlowTab !== 'autotest' && isAutotest) continue;
- const isDefault = (currentFlowTab === 'generate' && wf.name === (workflowBindings.generate || '3-file-codegen'))
- || (currentFlowTab === 'adjust' && wf.name === (workflowBindings.adjust || 'incremental-update'))
- || (currentFlowTab === 'autotest' && wf.name === (workflowBindings.autotest || 'autotest-pipeline'));
- sel.innerHTML += `<option value="${escapeHtml(wf.name)}"${isDefault ? ' selected' : ''}>${escapeHtml(wf.title || wf.name)} (${wf.stepCount} steps)</option>`;
- }
- } catch {}
- }
- function loadActiveFlowWorkflow() {
- // Skip if a tool (VLGenerate/WorkflowRun) is loading a workflow directly
- if (window._skipFlowAutoLoad) return;
- const sel = $('flowWfSelect');
- const defaults = { generate: workflowBindings.generate || '3-file-codegen', adjust: workflowBindings.adjust || 'incremental-update', autotest: workflowBindings.autotest || 'autotest-pipeline' };
- const name = sel.value || defaults[currentFlowTab] || '';
- if (name) loadFlowWorkflow(name);
- }
- // ── AutoTest 3-layer hierarchy UI ──
- function updateAtWfList() {
- const list = $('atWfList');
- if (currentFlowTab !== 'autotest') { list.classList.remove('visible'); return; }
- list.classList.add('visible');
- if (_atApps.length === 0) {
- if (_atPipelineStatus === 'running') {
- list.innerHTML = '<div style="padding:6px 10px;color:var(--yellow);font-size:10px;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--yellow);animation:csPulse 1.2s ease-in-out infinite;margin-right:6px;"></span>Generating test workflows...</div>';
- } else {
- list.innerHTML = '<div style="padding:6px 10px;color:var(--text2);font-size:10px;">No test workflows yet. Run autotest-pipeline to generate.</div>';
- }
- return;
- }
- let html = `<div class="at-wf-pipeline${_atActiveLevel === 'pipeline' ? ' active' : ''}" onclick="selectAtLevel('pipeline')">
- <span class="at-wf-dot ${_atPipelineStatus}"></span>
- <span>Pipeline Overview</span>
- <span style="margin-left:auto;font-weight:400;color:var(--text2);">${_atApps.reduce((s,a) => s + (a.caseCount||0), 0)} cases / ${_atApps.length} apps</span>
- </div>`;
- html += '<div class="at-wf-apps">';
- for (const app of _atApps) {
- const isActive = _atActiveLevel === 'app' && _atActiveApp === app.appId;
- html += `<div class="at-wf-app${isActive ? ' active' : ''}" onclick="selectAtLevel('app','${escapeHtml(app.appId)}')">
- <span class="at-wf-dot ${app.status || 'idle'}"></span>
- <span>${escapeHtml(app.appId)}</span><span class="app-count">(${app.caseCount})</span>
- </div>`;
- }
- html += '</div>';
- const selApp = _atApps.find(a => a.appId === _atActiveApp);
- if (selApp && selApp.cases && selApp.cases.length > 0) {
- html += '<div class="at-wf-cases">';
- for (const tc of selApp.cases) {
- const isActive = _atActiveLevel === 'testcase' && _atActiveCase === tc.id;
- const label = tc.name ? (tc.name.length > 28 ? tc.name.slice(0, 26) + '…' : tc.name) : tc.id;
- const stepsInfo = tc.stepsCount ? `${tc.stepsCount} steps` : '';
- const prioTag = tc.priority ? `<span style="font-size:8px;padding:0 3px;border-radius:2px;background:${tc.priority === 'P0' ? 'var(--red)' : 'var(--bg3)'};color:${tc.priority === 'P0' ? '#fff' : 'var(--text2)'};margin-left:4px;">${tc.priority}</span>` : '';
- html += `<div class="at-wf-case${isActive ? ' active' : ''}" onclick="selectAtLevel('testcase','${escapeHtml(selApp.appId)}','${escapeHtml(tc.id)}')" title="${escapeHtml(tc.name || tc.id)}">
- <span class="at-wf-dot ${tc.status || 'idle'}"></span><span>${escapeHtml(label)}</span>${prioTag}
- <span style="margin-left:auto;font-size:9px;color:var(--text2);">${stepsInfo}</span>
- </div>`;
- }
- html += '</div>';
- }
- list.innerHTML = html;
- }
- function selectAtLevel(level, appId, caseId) {
- _atActiveLevel = level;
- const sel = $('flowWfSelect');
- if (level === 'pipeline') {
- _atActiveApp = ''; _atActiveCase = '';
- _setFlowWfSelectOrStore('autotest-pipeline', sel);
- loadFlowWorkflow('autotest-pipeline');
- } else if (level === 'app') {
- _atActiveApp = appId || ''; _atActiveCase = '';
- const app = _atApps.find(a => a.appId === appId);
- if (app) { _setFlowWfSelectOrStore(app.name, sel); loadFlowWorkflow(app.name); }
- } else if (level === 'testcase') {
- _atActiveApp = appId || ''; _atActiveCase = caseId || '';
- const wfName = `autotest-tc-${(caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
- _setFlowWfSelectOrStore(wfName, sel);
- loadFlowWorkflow(wfName);
- }
- updateAtWfList();
- }
- /** Set flowWfSelect to name if option exists, otherwise store in data attr for runFlowWorkflow */
- function _setFlowWfSelectOrStore(name, sel) {
- const opt = [...sel.options].find(o => o.value === name);
- if (opt) { sel.value = name; }
- else { sel.dataset.pendingRun = name; }
- }
- // Ensure a live-updating autotest chat block exists for streaming progress
- let _autotestChatBlock = null;
- function _ensureAutotestChatBlock() {
- if (_autotestChatBlock && _autotestChatBlock.isConnected) return;
- const container = $('chatMessages');
- const block = document.createElement('div');
- block.className = 'msg system autotest-live-block';
- block.style.cssText = 'padding:6px 10px;border-radius:6px;background:var(--bg2);border-left:3px solid var(--accent);margin-bottom:8px;max-height:400px;overflow-y:auto;';
- block.innerHTML = '<div style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px;">AutoTest Pipeline</div>';
- container.appendChild(block);
- _autotestChatBlock = block;
- }
- // Load a named workflow into the Flow DAG iframe
- async function loadWorkflowIntoFlowTab(wfName) {
- try {
- await loadFlowWorkflow(wfName);
- } catch {}
- }
- function setAtAppStatus(appId, status) {
- const app = _atApps.find(a => a.appId === appId);
- if (app) { app.status = status; updateAtWfList(); }
- }
- function setAtCaseStatus(caseId, status) {
- for (const app of _atApps) {
- const tc = (app.cases || []).find(c => c.id === caseId);
- if (tc) { tc.status = status; updateAtWfList(); break; }
- }
- }
- // ── AutoTest Result Dialog ──
- function showAutotestResultDialog(passed, failed, softPassed, total, failures) {
- $('autotestResultSummary').innerHTML = `<div style="display:flex;gap:16px;font-size:14px;font-weight:600;">
- <span style="color:var(--green);">✅ ${passed} passed</span>
- ${softPassed > 0 ? `<span style="color:#cc0;">⚠️ ${softPassed} soft-passed</span>` : ''}
- <span style="color:var(--red);">❌ ${failed} failed</span>
- <span style="color:var(--text2);">/ ${total} total</span>
- </div>`;
- const failHtml = (failures || []).map(f =>
- `<div style="margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:4px;">
- <strong>${escapeHtml(f.name || f.caseId)}</strong><br>
- <span style="color:var(--red);">${escapeHtml(f.reason || 'Unknown')}</span>
- </div>`).join('');
- $('autotestResultFailures').innerHTML = failHtml || '<em>No failure details</em>';
- $('autotestResultModal').classList.add('open');
- }
- function autotestAction(action) {
- $('autotestResultModal').classList.remove('open');
- if (action === 'fix') {
- $('chatInput').value = '/test-fix';
- sendMessage();
- } else if (action === 'report') {
- // Direct AutoTestPipeline report call — reliable, no LLM drift
- $('chatInput').value = 'Call AutoTestPipeline with action "report" and display the result.';
- sendMessage();
- } else if (action === 'skip') {
- // Abort current session so any running fix/debug loop stops
- fetch('/api/abort', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ chatId: activeConvId }),
- }).catch(() => {});
- }
- }
- function onFlowWfSelectChange(val) {
- loadFlowWorkflow(val);
- // Persist selection to server for multi-window sync
- try { fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flowWorkflow: val, flowTab: currentFlowTab }) }); } catch {}
- }
- async function loadFlowWorkflow(name) {
- if (!name) return;
- try {
- const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
- // API returns workflow JSON directly (has .steps), or wrapped in .workflow
- const wf = data.workflow || (data.steps ? data : null);
- if (wf) {
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: wf, workflowName: name };
- });
- }
- } catch { setStatus('Failed to load workflow', 'red'); }
- }
- function importFlowJson() { $('flowJsonInput').click(); }
- $('flowJsonInput').addEventListener('change', (e) => {
- const file = e.target.files[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = () => {
- try {
- const json = JSON.parse(reader.result);
- const name = file.name.replace('.json', '');
- // Show in the DAG viewer
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: json, workflowName: name };
- });
- // Also save to server
- fetch(`/api/workflow/${encodeURIComponent(name)}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(json),
- }).then(() => {
- populateFlowWorkflowSelect();
- setStatus(`Loaded workflow: ${name}`, 'green');
- }).catch(() => {});
- } catch { setStatus('Invalid JSON file', 'red'); }
- };
- reader.readAsText(file);
- e.target.value = '';
- });
- // Drag-drop workflow JSON onto flow editor area
- document.addEventListener('dragover', (e) => {
- if (currentMode === 'flow') e.preventDefault();
- });
- document.addEventListener('drop', (e) => {
- if (currentMode !== 'flow') return;
- e.preventDefault();
- const file = e.dataTransfer.files[0];
- if (!file || !file.name.endsWith('.json')) return;
- const reader = new FileReader();
- reader.onload = () => {
- try {
- const json = JSON.parse(reader.result);
- const name = file.name.replace('.json', '');
- showModeIframe('workflow', '/workflow-editor.html', async () => {
- return { type: 'loadWorkflow', data: json, workflowName: name };
- });
- fetch(`/api/workflow/${encodeURIComponent(name)}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(json),
- }).then(() => {
- populateFlowWorkflowSelect();
- setStatus(`Dropped workflow: ${name}`, 'green');
- }).catch(() => {});
- } catch { setStatus('Invalid JSON file', 'red'); }
- };
- reader.readAsText(file);
- });
- // ===================== RUN WORKFLOW =====================
- async function runFlowWorkflow() {
- const sel = $('flowWfSelect');
- const wfName = sel.value || sel.dataset.pendingRun || '';
- if (!wfName) { setStatus('Select a workflow first', 'yellow'); return; }
- if (flowRunning) return;
- // Autotest tab: run tests directly via AutoTestPipeline tool (not chat)
- // This gives us real-time node highlighting + step-by-step progress via SSE
- if (currentFlowTab === 'autotest') {
- const isCase = wfName.startsWith('autotest-tc-');
- const isPipeline = wfName === 'autotest-pipeline';
- const isApp = wfName.startsWith('autotest-') && !isCase && !isPipeline;
- // Determine which case IDs to run
- let caseIds = null; // null = run all
- if (isCase) {
- caseIds = [wfName.replace('autotest-tc-', '')];
- } else if (isApp) {
- const appId = wfName.replace('autotest-', '');
- const app = _atApps.find(a => a.appId === appId);
- if (app?.cases) caseIds = app.cases.map(c => c.id);
- }
- await runAutotestDirect(caseIds);
- return;
- }
- // Generate / Adjust tab: prompt for description (null = user pressed Cancel → abort)
- const userRequest = prompt('Describe what to generate or modify (leave blank to use defaults):', '');
- if (userRequest === null) return;
- flowRunning = true;
- const btn = $('flowRunBtn');
- const statusEl = $('flowRunStatus');
- btn.disabled = true;
- btn.classList.add('running');
- btn.innerHTML = '⚙ Running...';
- statusEl.textContent = 'Starting...';
- setStatus('Workflow running...', 'yellow');
- // Clear previous node statuses in DAG iframe
- sendToWorkflowIframe({ type: 'clearStatus' });
- let filesCount = 0;
- let lastError = null;
- try {
- const res = await fetch('/api/workflow/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflowName: wfName, params: { userRequest, targetLang: 'en' } }),
- });
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- // Parse SSE: "event: type\ndata: json\n\n"
- const blocks = buffer.split('\n\n');
- buffer = blocks.pop(); // keep incomplete block
- for (const block of blocks) {
- let evtType = 'message', evtData = null;
- for (const line of block.split('\n')) {
- if (line.startsWith('event: ')) evtType = line.slice(7).trim();
- else if (line.startsWith('data: ')) {
- try { evtData = JSON.parse(line.slice(6)); } catch {}
- }
- }
- if (!evtData) continue;
- switch (evtType) {
- case 'node_start':
- sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'running', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
- statusEl.textContent = evtData.title || evtData.nodeId;
- break;
- case 'node_done':
- sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'done', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
- break;
- case 'node_error':
- sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'error', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
- lastError = evtData.error;
- break;
- case 'screenshot':
- debugLog('screenshot', evtData);
- break;
- case 'file_written':
- filesCount++;
- statusEl.textContent = `${filesCount} files written`;
- break;
- case 'done': {
- const realFiles = (evtData.filesWritten || []).filter(p => p && p !== '/');
- const realCount = realFiles.length || filesCount;
- if (realCount === 0) {
- lastError = 'Workflow completed but no source files were written';
- statusEl.textContent = 'Done (0 files — check metadata has filePath fields)';
- } else {
- statusEl.textContent = `Done! ${realCount} files`;
- }
- break;
- }
- case 'error':
- lastError = evtData.message;
- statusEl.textContent = 'Error: ' + (evtData.message || '').slice(0, 60);
- break;
- }
- }
- }
- } catch (e) {
- lastError = e.message;
- statusEl.textContent = 'Error';
- }
- // Reset button state
- flowRunning = false;
- btn.disabled = false;
- btn.classList.remove('running');
- btn.innerHTML = '▶ Run';
- await loadFileTree();
- setStatus(lastError ? 'Workflow finished with errors' : `Done: ${filesCount} files generated`, lastError ? 'red' : 'green');
- }
- /**
- * Run autotest directly via AutoTestPipeline tool (not through chat).
- * Gives us SSE events for real-time DAG node highlighting + step progress.
- * @param {string[]|null} caseIds - null = run all, array = run specific cases
- */
- async function runAutotestDirect(caseIds) {
- const btn = $('flowRunBtn');
- const statusEl = $('flowRunStatus');
- const selectedWorkflow = $('flowWfSelect').value || workflowBindings.autotest || 'autotest-pipeline';
- flowRunning = true;
- btn.disabled = true;
- btn.classList.add('running');
- btn.innerHTML = '⚙ Running...';
- statusEl.textContent = caseIds ? `Running ${caseIds.length} test(s)...` : 'Running all tests...';
- setStatus('AutoTest running...', 'yellow');
- // Reset case statuses to running
- _atPipelineStatus = selectedWorkflow === 'autotest-pipeline' ? 'running' : _atPipelineStatus;
- for (const app of _atApps) {
- for (const tc of (app.cases || [])) {
- if (!caseIds || caseIds.includes(tc.id)) tc.status = 'running';
- }
- app.status = 'running';
- }
- updateAtWfList();
- // Clear DAG node statuses
- sendToWorkflowIframe({ type: 'clearStatus' });
- // Open detail panel for live progress
- if (!$('detailPanel').classList.contains('open')) toggleDetailPanel();
- try {
- const toolInput = { action: selectedWorkflow === 'autotest-pipeline' ? 'full' : 'run' };
- if (caseIds) toolInput.caseIds = caseIds;
- const res = await fetch('/api/tools/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: 'AutoTestPipeline', input: toolInput }),
- });
- const data = await res.json();
- if (data.error) {
- statusEl.textContent = 'Error: ' + (data.error || '').slice(0, 80);
- setStatus('AutoTest failed', 'red');
- } else {
- const summaryText = typeof data.result === 'string'
- ? data.result
- : (data.result?.result || data.result || 'Done');
- statusEl.textContent = String(summaryText).slice(0, 120);
- setStatus('AutoTest complete', 'green');
- }
- } catch (e) {
- statusEl.textContent = 'Error: ' + e.message;
- setStatus('AutoTest failed', 'red');
- }
- flowRunning = false;
- btn.disabled = false;
- btn.classList.remove('running');
- btn.innerHTML = '▶ Run';
- // Refresh state from saved results
- await loadAtAppsFromWorkflows();
- }
- /** Send a postMessage to the workflow-editor iframe */
- function sendToWorkflowIframe(msg) {
- const container = $('iframeContainer');
- const iframe = container?.querySelector('iframe[data-tab="__mode_workflow__"]');
- if (iframe?.contentWindow) iframe.contentWindow.postMessage(msg, '*');
- }
- function forwardWorkflowEventToIframe(type, payload) {
- sendToWorkflowIframe({ type: 'workflowEvent', event: { ...(payload || {}), type } });
- }
- // ===================== EXPORT ZIP =====================
- // Import files into current project (adds to file tree)
- async function importFiles() { loadFolder(); }
- // Create new project from ZIP: extract to parent/newdir with helper files
- async function importZipAsProject() { importZip(); }
- // Export all files (VL + Process/ + .vl-code/ + helpers)
- async function exportAll() {
- setStatus('Exporting all files...', 'yellow');
- try {
- const res = await fetch('/api/export-zip?scope=all');
- if (!res.ok) { setStatus('Export failed', 'red'); return; }
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '.zip';
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- setStatus('Exported all files', 'green');
- } catch { setStatus('Export failed', 'red'); }
- }
- // Export VL files only (.vx, .sc, .cp, .vs, .vdb, .vth)
- async function exportVLOnly() {
- setStatus('Exporting VL files...', 'yellow');
- try {
- const res = await fetch('/api/export-zip?scope=vl');
- if (!res.ok) { setStatus('Export failed', 'red'); return; }
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '_VL.zip';
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- setStatus('Exported VL files', 'green');
- } catch { setStatus('Export failed', 'red'); }
- }
- // Legacy alias
- async function exportZip() { return exportAll(); }
- // ===================== COMPILE & PREVIEW =====================
- async function compileProject() {
- // Guard: check if current workspace is a VL project
- try {
- const proj = await api('/api/project');
- if (!proj.isVL) {
- setStatus('Cannot compile — no VL files in workspace', 'red');
- addMsg('assistant', '**Cannot compile:** Current workspace has no VL source files (.vx, .sc, .cp, .vs, .vdb). Please switch to a VL project workspace first.');
- return;
- }
- } catch {}
- const btn = $('compileBtn');
- btn.disabled = true;
- btn.innerHTML = '⏳ Compiling...';
- btn.style.opacity = '0.6';
- setStatus('Compiling project...', 'yellow');
- addMsg('assistant', 'Compiling project... Uploading VL files to cloud platform.');
- addDetailEntry('compile', 'Compile started — packaging VL files', null, 'info');
- const compileStart = Date.now();
- try {
- // Step 1: Push files to cloud (if we have a GID)
- let gid = $('cloudGid').value.trim();
- if (!gid) {
- // Try reading from project config
- try {
- const cfgRes = await fetch('/api/file?path=Config/ProjectConfig');
- if (cfgRes.ok) { const d = await cfgRes.json(); gid = (d.content || '').trim(); }
- } catch {}
- if (!gid) {
- try {
- const profile = normalizeProjectProfile(await api('/api/profile'));
- gid = getProfileGid(profile);
- } catch {}
- }
- }
- if (gid) {
- addDetailEntry('compile', `Pushing files to cloud (GID: ${gid})...`, null, 'info');
- try {
- const pushRes = await fetch('/api/cloud/sync/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gid }) });
- const pushData = await pushRes.json();
- if (pushData.error) addDetailEntry('compile', 'Push warning: ' + pushData.error, null, 'warn');
- else addDetailEntry('compile', `Pushed ${pushData.pushed || pushData.total || 0} files`, null, 'success');
- } catch (pushErr) {
- addDetailEntry('compile', 'Push failed: ' + pushErr.message, null, 'warn');
- }
- }
- // Step 2: Compile via VLCompile tool (returns JSON)
- const res = await fetch('/api/cloud/compile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetGid: gid ? Number(gid) : undefined }) });
- if (res.status === 401) {
- setStatus('Login required to compile', 'yellow');
- addMsg('assistant', 'Compile requires cloud login. Opening login dialog...');
- addDetailEntry('compile', 'Not authenticated — opening login', null, 'warn');
- openCloudLogin();
- return;
- }
- const elapsed = ((Date.now() - compileStart) / 1000).toFixed(1);
- const raw = await res.text();
- let data;
- try { data = JSON.parse(raw); } catch { data = { error: raw.substring(0, 200) }; }
- // VLCompile returns result as JSON string inside "result" field
- if (typeof data === 'string') { try { data = JSON.parse(data); } catch {} }
- if (data.result && typeof data.result === 'string') { try { data = JSON.parse(data.result); } catch { data = { error: data.result }; } }
- if (data.error && !data.success) {
- setStatus('Compile failed: ' + data.error, 'red');
- addMsg('assistant', `**Compile failed** (${elapsed}s): ${data.error}`);
- addDetailEntry('compile', 'Compile failed: ' + data.error, null, 'error');
- return;
- }
- // Track GID for reuse — persist to Config/ProjectConfig so next compile uses same project
- if (data.gid) {
- $('cloudGid').value = data.gid;
- fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) }).catch(() => {});
- }
- // Show results
- const urls = data.previewUrls || {};
- const keys = Object.keys(urls);
- const errList = data.errList || [];
- const errCount = data.errCount || errList.length;
- addDetailEntry('compile', `Compile response (${elapsed}s) — GID: ${data.gid || gid || 'none'}, errors: ${errCount}`, null, errCount > 0 ? 'warn' : 'success');
- if (keys.length > 0) {
- activatePreview(urls);
- const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
- const summary = errCount > 0
- ? `**Compile completed** in ${elapsed}s — ${errCount} error(s), ${keys.length} app(s)`
- : `**Compile success** in ${elapsed}s — ${keys.length} app(s) ready`;
- addMsg('assistant', `${summary} (GID: ${data.gid || gid})\n\n**Preview URLs:**\n${urlList}`);
- setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled — preview ready', errCount > 0 ? 'yellow' : 'green');
- } else if (!data.gid && !gid) {
- // Syntax check only — Lambda ran without a cloud workspace
- setStatus(errCount > 0 ? `Syntax errors (${errCount})` : 'Syntax OK — no cloud project', errCount > 0 ? 'yellow' : 'yellow');
- addMsg('assistant', `**Syntax check** in ${elapsed}s — ${errCount} error(s). No preview URL generated.\n\n> To deploy and get a preview URL, open the **Cloud** panel → create or select a workspace → compile again.`);
- } else {
- setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled (no preview URLs)', errCount > 0 ? 'yellow' : 'green');
- addMsg('assistant', `**Compile done** in ${elapsed}s (GID: ${data.gid || gid}) — ${errCount} error(s).`);
- }
- if (errCount > 0 && errList.length > 0) {
- const errLines = errList.map((e, i) => {
- if (typeof e === 'string') return ` ${i + 1}. ${e}`;
- if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
- return ` ${i + 1}. ${JSON.stringify(e)}`;
- }).join('\n');
- addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
- addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
- }
- } catch (e) {
- setStatus('Compile error', 'red');
- addMsg('assistant', `**Compile error:** ${e.message}`);
- } finally {
- btn.disabled = false;
- btn.innerHTML = '▶ Compile';
- btn.style.opacity = '1';
- }
- }
- autoResizeChatInput(true);
- init();
- </script>
- </body>
- </html>
|