index.html 476 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>VLCode Lite</title>
  7. <link id="dynFavicon" rel="icon" type="image/png" sizes="32x32" href="/assets/vlcode-lite-favicon-32.png?v=20260315">
  8. <link rel="icon" type="image/png" sizes="16x16" href="/assets/vlcode-lite-favicon-16.png?v=20260315">
  9. <link rel="icon" type="image/svg+xml" href="/assets/vlcode-lite-icon.svg?v=20260315">
  10. <!-- CodeMirror 5 (served locally from node_modules) -->
  11. <link rel="stylesheet" href="/lib/codemirror/lib/codemirror.css">
  12. <link rel="stylesheet" href="/lib/codemirror/addon/fold/foldgutter.css">
  13. <script src="/lib/codemirror/lib/codemirror.js"></script>
  14. <script src="/lib/codemirror/addon/edit/matchbrackets.js"></script>
  15. <script src="/lib/codemirror/addon/edit/closebrackets.js"></script>
  16. <script src="/lib/codemirror/addon/selection/active-line.js"></script>
  17. <script src="/lib/codemirror/addon/fold/foldcode.js"></script>
  18. <script src="/lib/codemirror/addon/fold/foldgutter.js"></script>
  19. <script src="/lib/codemirror/addon/fold/indent-fold.js"></script>
  20. <script src="/lib/codemirror/mode/javascript/javascript.js"></script>
  21. <script src="/lib/codemirror/mode/css/css.js"></script>
  22. <script src="/lib/codemirror/mode/xml/xml.js"></script>
  23. <script src="/lib/codemirror/mode/htmlmixed/htmlmixed.js"></script>
  24. <style>
  25. :root {
  26. --bg: #0d1117; --bg1: #0a0f14; --bg2: #161b22; --bg3: #21262d; --hover: #1b2129; --border: #30363d;
  27. --text: #e6edf3; --text2: #8b949e; --accent: #58a6ff; --accent-rgb: 88, 166, 255; --green: #3fb950;
  28. --yellow: #d29922; --orange: #f0883e; --red: #f85149; --purple: #a371f7; --blue: #79c0ff; --cyan: #5ccfe6;
  29. --font: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  30. }
  31. * { margin:0; padding:0; box-sizing:border-box; }
  32. body { background:var(--bg); color:var(--text); font-family:var(--font); font-size:13px; height:100vh; display:flex; flex-direction:column; }
  33. /* Header */
  34. 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; }
  35. body.desktop-app header { -webkit-app-region:drag; padding-left:84px; }
  36. body.desktop-app header button,
  37. body.desktop-app header input,
  38. body.desktop-app header select,
  39. body.desktop-app header textarea,
  40. body.desktop-app header .ws-current,
  41. body.desktop-app header .ws-popover,
  42. body.desktop-app header .wf-selector,
  43. body.desktop-app header .auth-status,
  44. body.desktop-app header .llm-badge,
  45. body.desktop-app header .ctx-bar { -webkit-app-region:no-drag; }
  46. header h1 { font-size:13px; color:var(--accent); font-weight:600; white-space:nowrap; }
  47. header .info { color:var(--text2); font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  48. header .spacer { flex:1; }
  49. header .ctx-bar { display:flex; align-items:center; gap:6px; }
  50. header .ctx-bar .bar { width:100px; height:5px; background:var(--bg3); border-radius:3px; overflow:hidden; }
  51. header .ctx-bar .bar-fill { height:100%; background:var(--green); border-radius:3px; transition:width 0.3s; }
  52. .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; }
  53. .hdr-btn:hover { background:var(--border); color:var(--text); }
  54. .hdr-btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
  55. #compileBtn { background:rgba(63,185,80,0.15); color:#a8f0b2; border-color:rgba(63,185,80,0.35); }
  56. #compileBtn:hover { background:rgba(63,185,80,0.22); color:#fff; border-color:rgba(63,185,80,0.55); }
  57. /* Mode toggle switch */
  58. .mode-toggle { display:inline-flex; align-items:center; cursor:pointer; vertical-align:middle; }
  59. .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; }
  60. .mode-toggle-track.human { background:#2ea043; border-color:#2ea043; }
  61. .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; }
  62. .mode-toggle-track .mode-label-human { color:rgba(255,255,255,.4); }
  63. .mode-toggle-track .mode-label-ai { color:#fff; }
  64. .mode-toggle-track.human .mode-label-human { color:#fff; }
  65. .mode-toggle-track.human .mode-label-ai { color:rgba(255,255,255,.4); }
  66. .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; }
  67. .mode-toggle-track.human .mode-toggle-thumb { right:auto; left:2px; }
  68. .hdr-btn-primary:hover { background:#79b8ff; }
  69. main { flex:1; display:flex; overflow:hidden; }
  70. /* File sidebar */
  71. .sidebar { width:220px; background:var(--bg2); border-right:1px solid var(--border); display:flex; flex-direction:column; position:relative; }
  72. .sidebar h3 { padding:10px 12px; font-size:11px; color:var(--text2); text-transform:uppercase; letter-spacing:1px; }
  73. .file-tree { flex:1; overflow-y:auto; padding:0 6px 6px; }
  74. .file-tree .category { margin-bottom:6px; }
  75. .file-tree .cat-name { color:var(--accent); font-size:10px; padding:3px 8px; cursor:pointer; }
  76. .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; }
  77. .file-tree .file:hover { background:var(--bg3); color:var(--text); }
  78. .file-tree .file.active { background:var(--accent); color:#fff; }
  79. /* Workflow selector dropdown */
  80. .wf-selector { position:relative; }
  81. .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); }
  82. .wf-dropdown.open { display:block; }
  83. .wf-item { padding:6px 10px; cursor:pointer; font-size:11px; display:flex; align-items:center; justify-content:space-between; }
  84. .wf-item:hover { background:var(--bg3); }
  85. .wf-item .wf-name { color:var(--text); }
  86. .wf-item .wf-steps { color:var(--text2); font-size:9px; }
  87. .wf-item .wf-view { color:var(--accent); font-size:9px; cursor:pointer; margin-left:6px; }
  88. .wf-item .wf-view:hover { text-decoration:underline; }
  89. /* Sidebar actions */
  90. .sidebar-actions { display:flex; gap:3px; padding:0 10px 6px; flex-wrap:wrap; }
  91. .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; }
  92. .sidebar-actions .sa-btn:hover { background:var(--border); color:var(--text); }
  93. .sidebar-actions .sa-btn.active { background:rgba(88,166,255,0.12); color:var(--accent); border-color:rgba(88,166,255,0.35); }
  94. .sidebar-actions .sa-btn.sa-danger:hover { background:var(--red); color:#fff; border-color:var(--red); }
  95. .sidebar-actions .sa-btn .sa-icon { font-size:10px; }
  96. /* File tree context menu */
  97. .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); }
  98. .ctx-menu.open { display:block; }
  99. .ctx-menu-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); }
  100. .ctx-menu-item:hover { background:var(--bg3); color:var(--text); }
  101. .ctx-menu-item.danger { color:var(--red); }
  102. .ctx-menu-item.danger:hover { background:var(--red); color:#fff; }
  103. .ctx-menu-sep { height:1px; background:var(--border); margin:3px 0; }
  104. /* File icons — VS Code / Claude Code inspired */
  105. .file-icon { display:inline-block; width:14px; text-align:center; margin-right:4px; font-size:11px; flex-shrink:0; }
  106. .type-badge { display:inline-block; font-size:8px; padding:1px 3px; border-radius:2px; margin-right:3px; font-weight:600; }
  107. .type-app { background:#1f6feb33; color:var(--accent); }
  108. .type-section { background:#3fb95033; color:var(--green); }
  109. .type-component { background:#d2992233; color:var(--yellow); }
  110. .type-service { background:#f8514933; color:var(--red); }
  111. .type-database { background:#8b949e33; color:var(--text2); }
  112. .type-theme { background:#a371f733; color:var(--purple); }
  113. .type-process { background:#a371f720; color:#c49bff; }
  114. .type-json { background:#d2992220; color:#e0ad40; }
  115. .type-doc { background:#8b949e20; color:#9da5ae; }
  116. .type-image { background:#f0883e20; color:#f0883e; }
  117. .type-report { background:#3fb95020; color:#3fb950; }
  118. .type-log { background:#8b949e20; color:#8b949e; }
  119. .type-config { background:#d2992220; color:#d29922; }
  120. .type-workflow { background:#a371f720; color:#a371f7; }
  121. /* Mode tabs (Code / Map / Flow) */
  122. .mode-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); padding:0 8px; gap:2px; }
  123. .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; }
  124. .mode-tab:hover { color:var(--text); background:var(--bg3); }
  125. .mode-tab.active { color:var(--accent); border-bottom-color:var(--accent); background:var(--bg); }
  126. /* Folder path link */
  127. .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); }
  128. .folder-path:hover { color:var(--accent); background:var(--bg3); }
  129. .folder-path .fp-icon { font-size:10px; flex-shrink:0; }
  130. /* Workflow binding labels */
  131. .wf-binding { display:flex; align-items:center; justify-content:space-between; padding:5px 10px; font-size:10px; border-bottom:1px solid var(--border); }
  132. .wf-binding .wf-b-label { color:var(--text2); }
  133. .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; }
  134. /* Auto-linked URLs */
  135. .msg.assistant .content-text a { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
  136. .msg.assistant .content-text a:hover { color:#79b8ff; }
  137. /* Preview URL list in sidebar */
  138. .project-config { border-top:1px solid var(--border); }
  139. .pc-header { font-size:9px; color:var(--text2); padding:6px 12px; cursor:pointer; text-transform:uppercase; letter-spacing:0.5px; margin:0; }
  140. .pc-header:hover { color:var(--text); }
  141. .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; }
  142. .pc-file:hover { background:var(--bg3); color:var(--text); }
  143. .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; }
  144. .pc-sync-btn:hover { color:var(--accent); border-color:var(--accent); }
  145. .doc-id-panel-note { padding:6px 12px 2px; font-size:9px; color:var(--text2); line-height:1.5; }
  146. .doc-id-panel-actions { display:flex; gap:6px; padding:6px 12px 4px; }
  147. .doc-id-panel-actions .pc-sync-btn { flex:1; padding:2px 0; }
  148. .doc-id-grid { display:flex; flex-direction:column; gap:6px; padding:6px 12px 10px; }
  149. .doc-id-grid .settings-doc-card { padding:8px; }
  150. .doc-id-section-title { padding:4px 12px 0; font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
  151. .doc-id-section-toggle { display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
  152. .doc-id-section-toggle:hover { color:var(--text); }
  153. .settings-doc-card.is-locked { opacity:0.78; }
  154. .settings-doc-card.is-locked input { opacity:0.65; cursor:not-allowed; }
  155. .pc-doc-toggle { font-size:9px; opacity:0.5; cursor:pointer; padding:1px 4px; border-radius:3px; background:none; border:none; color:var(--text2); }
  156. .pc-doc-toggle:hover { opacity:1; }
  157. .pc-doc-toggle.active { color:var(--green); opacity:1; }
  158. .preview-urls { padding:4px 12px 8px; }
  159. .preview-urls h4 { font-size:9px; color:var(--text2); margin-bottom:4px; text-transform:uppercase; letter-spacing:0.5px; }
  160. .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; }
  161. .preview-url-item:hover { background:var(--bg3); }
  162. .preview-url-item .pui-name { font-weight:600; min-width:40px; }
  163. .preview-url-item .pui-url { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
  164. /* Cloud panel */
  165. .cloud-dot { width:6px; height:6px; border-radius:50%; background:var(--red); display:inline-block; }
  166. .cloud-dot.connected { background:var(--green); }
  167. .cloud-section { }
  168. .cloud-user { padding:4px 12px; font-size:10px; color:var(--text); display:flex; align-items:center; gap:6px; }
  169. .cloud-user .cu-name { font-weight:600; color:var(--accent); }
  170. .cloud-user .cu-company { color:var(--text2); font-size:9px; }
  171. .cloud-actions { display:flex; gap:4px; padding:4px 12px; }
  172. .cloud-actions .sa-btn { flex:1; text-align:center; font-size:9px; padding:3px 0; }
  173. .cloud-gid { padding:2px 0; }
  174. .cloud-status { padding:4px 12px; font-size:9px; }
  175. .cloud-status.syncing { color:var(--yellow); }
  176. .cloud-status.ok { color:var(--green); }
  177. .cloud-status.error { color:var(--red); }
  178. .cloud-app-item { padding:3px 12px; font-size:10px; cursor:pointer; color:var(--text2); display:flex; justify-content:space-between; align-items:center; }
  179. .cloud-app-item:hover { background:var(--bg3); color:var(--text); }
  180. .cloud-app-item .ca-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
  181. .cloud-app-item .ca-gid { font-size:8px; color:var(--text2); opacity:0.6; }
  182. #cloudBtn.connected { color:var(--green); }
  183. .cloud-login-tabs { display:flex; gap:2px; margin-bottom:12px; border-bottom:1px solid var(--border); }
  184. .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; }
  185. .cl-tab:hover { color:var(--text); }
  186. .cl-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
  187. .cl-panel { }
  188. .cl-panel code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:10px; }
  189. /* Auth status in header (Claude Code style) */
  190. .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); }
  191. .auth-status:hover { background:var(--border); }
  192. .auth-status .auth-dot { width:6px; height:6px; border-radius:50%; background:var(--red); flex-shrink:0; }
  193. .auth-status .auth-dot.ok { background:var(--green); }
  194. .auth-status .auth-name { color:var(--text); max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  195. .auth-status .auth-label { color:var(--text2); }
  196. /* Message context toggle */
  197. .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; }
  198. .msg:hover .msg-ctx-toggle { opacity:0.6; }
  199. .msg-ctx-toggle:hover { opacity:1 !important; background:var(--bg3); }
  200. .msg-ctx-toggle.excluded { color:var(--red); opacity:0.8 !important; }
  201. .msg.excluded-msg { opacity:0.4; border-left:2px solid var(--red); }
  202. .msg.excluded-msg .label::after { content:' (excluded from context)'; color:var(--red); font-size:8px; }
  203. /* Preview mode tab */
  204. .mode-tab[data-mode="preview"] { color:var(--green); }
  205. .mode-tab[data-mode="preview"].active { color:var(--green); border-bottom-color:var(--green); }
  206. .preview-bar { display:flex; align-items:center; gap:8px; padding:4px 12px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:10px; }
  207. .preview-bar .preview-url { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  208. .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; }
  209. .preview-bar .preview-btn:hover { background:var(--border); color:var(--text); }
  210. .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; }
  211. /* Flow toolbar */
  212. .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; }
  213. .flow-sub-tabs { display:flex; gap:2px; }
  214. .flow-sub-tab { padding:3px 12px; cursor:pointer; color:var(--text2); font-size:10px; font-weight:600; border-radius:4px; transition:all 0.15s; }
  215. .flow-sub-tab:hover { color:var(--text); background:var(--bg3); }
  216. .flow-sub-tab.active { color:var(--accent); background:var(--bg3); }
  217. .flow-actions { display:flex; align-items:center; gap:6px; }
  218. .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; }
  219. .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; }
  220. .flow-btn:hover { background:var(--border); color:var(--text); }
  221. .flow-btn-run { background:var(--green); color:#000; border-color:var(--green); font-weight:700; }
  222. .flow-btn-run:hover { background:#2ea043; }
  223. .flow-btn-run:disabled { opacity:0.4; cursor:not-allowed; }
  224. .flow-btn-run.running { background:var(--orange); border-color:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
  225. .flow-run-status { font-size:9px; color:var(--text2); max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  226. /* Flow tab — workflow picker (Generate / Adjust sub-tabs) */
  227. .flow-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); font-size:10px; }
  228. .flow-wf-list.visible { display:flex; flex-wrap:wrap; gap:4px; padding:5px 10px; }
  229. .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; }
  230. .flow-wf-item:hover { background:var(--bg3); color:var(--text); border-color:var(--accent); }
  231. .flow-wf-item.active { background:var(--accent); color:#fff; border-color:var(--accent); }
  232. .flow-wf-item .fwi-name { font-weight:600; font-size:10px; }
  233. .flow-wf-item .fwi-desc { font-size:8.5px; opacity:0.7; line-height:1.2; }
  234. .flow-wf-item.active .fwi-desc { opacity:0.85; }
  235. /* AutoTest 3-layer workflow hierarchy */
  236. .at-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); max-height:200px; overflow-y:auto; font-size:10px; }
  237. .at-wf-list.visible { display:block; }
  238. .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); }
  239. .at-wf-pipeline:hover { background:var(--bg2); }
  240. .at-wf-pipeline.active { background:var(--bg2); border-left-color:var(--orange); }
  241. .at-wf-apps { display:flex; flex-wrap:wrap; gap:4px; padding:4px 10px; border-bottom:1px solid var(--border); }
  242. .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; }
  243. .at-wf-app:hover { color:var(--text); border-color:var(--border); }
  244. .at-wf-app.active { color:var(--accent); border-color:var(--accent); background:rgba(100,180,255,0.1); }
  245. .at-wf-app .app-count { font-size:8px; color:var(--text2); margin-left:2px; }
  246. .at-wf-cases { display:flex; flex-wrap:wrap; gap:3px; padding:3px 10px; }
  247. .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; }
  248. .at-wf-case:hover { color:var(--text); border-color:var(--border); }
  249. .at-wf-case.active { color:var(--accent); border-color:var(--accent); }
  250. .at-wf-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; display:inline-block; }
  251. .at-wf-dot.idle { background:var(--text2); }
  252. .at-wf-dot.running { background:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
  253. .at-wf-dot.done { background:var(--green); }
  254. .at-wf-dot.error { background:var(--red); }
  255. .at-wf-dot.skipped { background:#cc0; }
  256. .at-wf-dot.soft { background:#cc0; }
  257. /* Editor + Chat */
  258. .content { flex:1; display:flex; flex-direction:column; }
  259. .panels { flex:1; display:flex; overflow:hidden; margin-right:400px; }
  260. /* Editor */
  261. .editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); }
  262. .editor-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:32px; }
  263. .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; }
  264. .editor-tabs .tab.active { color:var(--text); border-bottom-color:var(--accent); background:var(--bg); }
  265. .editor-tabs .tab .tab-icon { font-size:10px; opacity:0.7; }
  266. .editor-tabs .tab .tab-close { font-size:9px; opacity:0; padding:1px 3px; border-radius:3px; line-height:1; }
  267. .editor-tabs .tab:hover .tab-close { opacity:0.5; }
  268. .editor-tabs .tab .tab-close:hover { opacity:1; background:var(--bg3); }
  269. .editor-area { flex:1; position:relative; overflow:hidden; }
  270. .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; }
  271. .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); }
  272. .editor-area .CodeMirror-gutters { background:var(--bg2); border-right:1px solid var(--border); }
  273. .editor-area .CodeMirror-linenumber { color:var(--text2); opacity:0.5; padding:0 8px 0 4px; }
  274. .editor-area .CodeMirror-cursor { border-left:2px solid var(--accent); }
  275. .editor-area .CodeMirror-selected { background:rgba(88,166,255,0.15); }
  276. .editor-area .CodeMirror-activeline-background { background:rgba(255,255,255,0.03); }
  277. .editor-area .CodeMirror-matchingbracket { color:var(--green) !important; text-decoration:underline; }
  278. .editor-area .CodeMirror-foldgutter { width:14px; }
  279. .editor-area .CodeMirror-foldgutter-open, .editor-area .CodeMirror-foldgutter-folded { color:var(--text2); }
  280. /* Dark theme token colors — VL optimized, no black text */
  281. .editor-area .cm-keyword { color:#ff7b72; font-weight:600; }
  282. .editor-area .cm-variable-2 { color:#ffa657; }
  283. .editor-area .cm-def { color:#d2a8ff; }
  284. .editor-area .cm-string { color:#a5d6ff; }
  285. .editor-area .cm-number { color:#79c0ff; }
  286. .editor-area .cm-atom { color:#79c0ff; }
  287. .editor-area .cm-type { color:#79c0ff; font-weight:600; }
  288. .editor-area .cm-builtin { color:#7ee787; }
  289. .editor-area .cm-tag { color:#7ee787; font-weight:600; }
  290. .editor-area .cm-attribute { color:#79c0ff; }
  291. .editor-area .cm-property { color:#c9d1d9; }
  292. .editor-area .cm-comment { color:#8b949e; font-style:italic; }
  293. .editor-area .cm-meta { color:#8b949e; }
  294. .editor-area .cm-qualifier { color:#6e7681; }
  295. .editor-area .cm-indent-marker { color:#6e7681; }
  296. .editor-area .cm-section-header { color:#d2a8ff; font-weight:700; font-size:14px; }
  297. .editor-area .cm-operator { color:#e6edf3; }
  298. .editor-area .cm-variable { color:#c9d1d9; }
  299. .editor-area .cm-variable-3 { color:#ffa657; }
  300. .editor-area .cm-bracket { color:#e6edf3; }
  301. .editor-area .cm-punctuation { color:#8b949e; }
  302. .editor-area .cm-link { color:#58a6ff; }
  303. .editor-area .CodeMirror-line { color:var(--text); }
  304. .editor-area .CodeMirror pre.CodeMirror-line { color:#e6edf3; } /* JSON punctuation: unstyled spans inherit this */
  305. .editor-area .CodeMirror-scroll { scrollbar-color:var(--bg3) transparent; overflow:scroll !important; }
  306. .editor-area .CodeMirror-hscrollbar { height:10px !important; }
  307. .editor-area .CodeMirror-hscrollbar div { background:var(--bg3) !important; border-radius:4px; }
  308. .editor-area .iframe-container { display:none; width:100%; height:100%; }
  309. .editor-area .iframe-container iframe { width:100%; height:100%; border:none; background:#fff; }
  310. .editor-placeholder { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); color:var(--text2); text-align:center; line-height:2; }
  311. /* Code preview (read-only syntax view) */
  312. .code-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; }
  313. .code-preview pre { padding:12px; font-family:var(--font); font-size:13px; line-height:1.6; tab-size:2; margin:0; counter-reset:line; }
  314. .code-preview .line { display:block; white-space:pre-wrap; word-break:break-all; }
  315. .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; }
  316. /* VL Syntax colors */
  317. .code-preview .kw { color:#ff7b72; } /* keywords: SERVICE, PUBLIC_SERVICE, SECTION, EVENT, etc */
  318. .code-preview .str { color:#a5d6ff; } /* strings */
  319. .code-preview .cmt { color:#8b949e; font-style:italic; } /* comments */
  320. .code-preview .var { color:#ffa657; } /* $variables */
  321. .code-preview .evt { color:#d2a8ff; } /* @events */
  322. .code-preview .type { color:#79c0ff; } /* type names */
  323. .code-preview .num { color:#79c0ff; } /* numbers */
  324. .code-preview .tag { color:#7ee787; } /* <Component-X>, <Section-Y> */
  325. .code-preview .prop { color:#d2a8ff; } /* property keys in JSON */
  326. /* Markdown preview */
  327. .md-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; padding:16px 24px; color:var(--text); line-height:1.7; }
  328. .md-preview h1 { font-size:20px; border-bottom:1px solid var(--border); padding-bottom:8px; margin:16px 0 12px; }
  329. .md-preview h2 { font-size:17px; border-bottom:1px solid var(--border); padding-bottom:6px; margin:14px 0 10px; }
  330. .md-preview h3 { font-size:14px; margin:12px 0 8px; }
  331. .md-preview p { margin:8px 0; }
  332. .md-preview ul, .md-preview ol { margin:8px 0; padding-left:24px; }
  333. .md-preview code { background:var(--bg3); padding:2px 5px; border-radius:3px; font-size:12px; }
  334. .md-preview pre { background:var(--bg3); padding:12px; border-radius:6px; overflow-x:auto; margin:8px 0; }
  335. .md-preview pre code { background:none; padding:0; }
  336. .md-preview blockquote { border-left:3px solid var(--accent); padding-left:12px; color:var(--text2); }
  337. /* Chat panel — fixed floating, draggable, always visible */
  338. .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; }
  339. .chat-panel.floating { border-radius:8px; height:auto; bottom:auto; resize:both; overflow:hidden; min-height:300px; min-width:300px; max-width:800px; }
  340. .chat-resize-handle { position:absolute; left:-3px; top:0; bottom:0; width:6px; cursor:col-resize; z-index:101; }
  341. .chat-resize-handle:hover, .chat-resize-handle.dragging { background:var(--accent); opacity:0.3; }
  342. .chat-panel.collapsed { width:36px !important; min-width:36px; }
  343. .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; }
  344. .chat-panel.collapsed .chat-header { writing-mode:vertical-rl; text-orientation:mixed; padding:12px 6px; cursor:pointer; justify-content:center; border-bottom:none; }
  345. .chat-panel.collapsed .chat-header>*:not(.chat-collapse-btn) { display:none; }
  346. .chat-panel.collapsed .chat-collapse-btn { writing-mode:horizontal-tb; }
  347. .chat-collapse-btn { background:none; border:none; color:var(--text2); cursor:pointer; font-size:12px; padding:2px 4px; border-radius:3px; }
  348. .chat-collapse-btn:hover { color:var(--text); background:var(--bg3); }
  349. /* Detail Panel */
  350. .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; }
  351. .detail-panel.open { display:flex; }
  352. .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; }
  353. .detail-header .dh-title { font-weight:600; color:var(--orange); }
  354. .detail-body { flex:1; overflow-y:auto; padding:8px; font-size:10px; font-family:var(--font); }
  355. .detail-entry { margin-bottom:6px; padding:4px 6px; border-left:2px solid var(--border); }
  356. .detail-entry.info { border-left-color:var(--accent); }
  357. .detail-entry.success { border-left-color:var(--green); }
  358. .detail-entry.error { border-left-color:var(--red); }
  359. .detail-entry.warn { border-left-color:var(--orange); }
  360. .detail-entry.depth-1 { margin-left:14px; border-left-style:dashed; }
  361. .detail-entry.depth-2 { margin-left:28px; border-left-style:dotted; }
  362. .detail-entry.depth-3 { margin-left:42px; border-left-style:dotted; opacity:0.85; }
  363. .detail-entry .de-time { color:var(--text2); font-size:8px; }
  364. .detail-entry .de-phase { color:var(--orange); font-weight:600; margin-left:4px; font-size:9px; }
  365. .detail-entry .de-agent { color:var(--purple); font-weight:600; margin-left:4px; font-size:9px; }
  366. .detail-entry .de-msg { color:var(--text); margin-top:2px; white-space:pre-wrap; word-break:break-word; }
  367. .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; }
  368. .detail-entry .de-data.collapsed { max-height:40px; overflow:hidden; position:relative; }
  369. .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; }
  370. /* Step card in detail panel — enhanced workflow step display */
  371. .detail-step-card { margin:6px 0; background:var(--bg2); border-radius:5px; border:1px solid var(--border); overflow:hidden; }
  372. .detail-step-card.running { border-color:var(--orange); }
  373. .detail-step-card.done { border-color:var(--green); }
  374. .detail-step-card.error { border-color:var(--red); }
  375. .detail-step-card.skipped { border-color:var(--text2); opacity:0.7; }
  376. .dsc-header { display:flex; align-items:center; gap:6px; padding:5px 8px; cursor:pointer; font-size:10px; }
  377. .dsc-header:hover { background:var(--bg3); }
  378. .dsc-icon { font-size:12px; width:16px; text-align:center; }
  379. .dsc-title { color:var(--text); font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  380. .dsc-type { color:var(--accent); font-size:8px; background:var(--bg); padding:1px 5px; border-radius:8px; }
  381. .dsc-duration { color:var(--text2); font-size:8px; }
  382. .dsc-body { padding:0 8px 6px; display:none; }
  383. .dsc-body.open { display:block; }
  384. .dsc-section { margin:4px 0; }
  385. .dsc-section-header { display:flex; align-items:center; gap:4px; font-size:9px; color:var(--text2); cursor:pointer; padding:2px 0; }
  386. .dsc-section-header:hover { color:var(--accent); }
  387. .dsc-section-header .dsc-arrow { font-size:8px; transition:transform 0.15s; }
  388. .dsc-section-header .dsc-arrow.open { transform:rotate(90deg); }
  389. .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; }
  390. .dsc-section-content.open { display:block; }
  391. .dsc-section-content.truncated::after { content:'(truncated)'; color:var(--orange); font-style:italic; }
  392. .dsc-actions { display:flex; gap:6px; margin-top:6px; padding-top:4px; border-top:1px solid var(--border); }
  393. .dsc-rerun-btn { background:none; border:1px solid var(--accent); color:var(--accent); font-size:9px; padding:2px 8px; border-radius:3px; cursor:pointer; }
  394. .dsc-rerun-btn:hover { background:var(--accent); color:var(--bg); }
  395. /* Hover action buttons on step card header */
  396. .dsc-hover-actions { display:none; gap:3px; margin-left:auto; flex-shrink:0; }
  397. .detail-step-card:hover .dsc-hover-actions { display:flex; }
  398. .dsc-hover-btn { background:none; border:none; color:var(--text2); font-size:10px; cursor:pointer; padding:1px 4px; border-radius:3px; line-height:1; }
  399. .dsc-hover-btn:hover { background:var(--bg3); color:var(--accent); }
  400. /* Step card context menu */
  401. .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); }
  402. .step-ctx-menu.open { display:block; }
  403. .step-ctx-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); display:flex; align-items:center; gap:8px; }
  404. .step-ctx-item:hover { background:var(--bg3); color:var(--text); }
  405. .step-ctx-item .sci-icon { width:16px; text-align:center; font-size:12px; }
  406. .step-ctx-item .sci-label { flex:1; }
  407. .step-ctx-item .sci-hint { font-size:9px; color:var(--text2); opacity:0.6; }
  408. .step-ctx-sep { height:1px; background:var(--border); margin:3px 0; }
  409. /* File list in step card */
  410. .dsc-file { font-size:9px; color:var(--green); padding:1px 0; }
  411. .dsc-file::before { content:'📄 '; }
  412. /* Re-run dialog variable rows */
  413. .rr-var-row { margin:4px 0; }
  414. .rr-var-name { font-size:10px; font-weight:600; color:var(--accent); margin-bottom:2px; }
  415. .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; }
  416. .rr-var-val:focus { border-color:var(--accent); outline:none; }
  417. /* Agent group in detail panel */
  418. .detail-agent-group { margin:4px 0; background:var(--bg2); border-radius:4px; border:1px solid var(--border); overflow:hidden; }
  419. .detail-agent-header { display:flex; align-items:center; gap:6px; padding:4px 8px; cursor:pointer; font-size:10px; }
  420. .detail-agent-header:hover { background:var(--bg3); }
  421. .detail-agent-header .dag-icon { color:var(--purple); font-size:11px; }
  422. .detail-agent-header .dag-name { color:var(--accent); font-weight:600; }
  423. .detail-agent-header .dag-desc { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  424. .detail-agent-header .dag-status { font-size:9px; }
  425. .detail-agent-children { padding:0 4px 4px; }
  426. .detail-agent-children.collapsed { display:none; }
  427. .detail-entry.stream-box { border-left-color:var(--accent); padding:0; }
  428. .de-stream-header { display:flex; align-items:center; gap:6px; padding:4px 6px; cursor:pointer; background:var(--bg2); border-radius:3px 3px 0 0; }
  429. .de-stream-header:hover { background:var(--hover); }
  430. .de-stream-label { color:var(--orange); font-weight:600; font-size:9px; }
  431. .de-stream-size { color:var(--text2); font-size:8px; margin-left:auto; }
  432. .de-stream-toggle { color:var(--text2); font-size:8px; transition:transform .2s; }
  433. .stream-box.collapsed .de-stream-toggle { transform:rotate(-90deg); }
  434. .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; }
  435. .stream-box.collapsed .de-stream-content { display:none; }
  436. /* LLM communication phase colors */
  437. .detail-entry .de-phase[data-phase="llm"] { color:var(--purple); }
  438. .detail-entry .de-phase[data-phase="tool-call"] { color:var(--blue); }
  439. .detail-entry .de-phase[data-phase="tool-result"] { color:var(--green); }
  440. .detail-entry .de-phase[data-phase="var"] { color:var(--cyan, #5ccfe6); }
  441. .detail-entry .de-phase[data-phase="file"] { color:var(--yellow); }
  442. .detail-entry .de-phase[data-phase="step"] { color:var(--accent); }
  443. .detail-entry .de-phase[data-phase="node"] { color:var(--accent); font-weight:600; }
  444. .detail-entry .de-phase[data-phase="tool"] { color:var(--blue); }
  445. .detail-entry .de-phase[data-phase="result"] { color:var(--green); }
  446. /* Thinking stream box: distinct purple accent */
  447. .detail-entry.stream-box.thinking-stream { border-left-color:var(--purple); }
  448. .thinking-stream .de-stream-label { color:var(--purple); }
  449. .thinking-stream .de-stream-content { color:var(--text2); font-style:italic; }
  450. /* Workflow LLM chat streaming */
  451. .wf-tool-full.collapsed { display:none; }
  452. /* Message truncation + show more — only for extremely long messages */
  453. .msg.assistant .content-text.truncated { max-height:2000px; overflow:hidden; position:relative; }
  454. .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; }
  455. .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; }
  456. .msg-toggle:hover { background:var(--accent); color:#fff; }
  457. /* Compact mode — collapse tool groups */
  458. .chat-panel.compact .tool-group .tool-body { display:none !important; }
  459. .chat-panel.compact .msg.assistant .content-text.auto-truncate { max-height:100px; overflow:hidden; }
  460. .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); }
  461. .chat-action-group { display:flex; align-items:center; gap:6px; min-width:0; }
  462. .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; }
  463. .chat-actions .ca-btn:hover { background:var(--border); color:var(--text); }
  464. .chat-actions .ca-btn.ca-primary { color:var(--text); background:rgba(88,166,255,0.09); border-color:rgba(88,166,255,0.22); }
  465. .chat-actions .ca-btn.ca-primary:hover { background:rgba(88,166,255,0.16); border-color:rgba(88,166,255,0.38); }
  466. .chat-actions .ca-btn.ca-log { color:var(--orange); border-color:rgba(240,136,62,0.24); background:rgba(240,136,62,0.08); }
  467. .chat-actions .ca-btn.ca-log:hover { background:rgba(240,136,62,0.14); color:#ffd4b6; }
  468. .ca-menu { position:relative; }
  469. .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; }
  470. .ca-menu.open .ca-menu-panel { display:block; }
  471. .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; }
  472. .ca-menu-item:hover { background:var(--bg3); color:var(--text); }
  473. .ca-menu-item.menu-accent { color:var(--accent); }
  474. .ca-menu-item.menu-log { color:var(--orange); }
  475. /* Workflow progress widget in chat */
  476. .wf-progress { background:var(--bg2); border:1px solid var(--border); border-radius:6px; margin:6px 0; padding:8px 10px; font-size:10px; }
  477. .wf-progress-header { display:flex; align-items:center; gap:6px; margin-bottom:6px; font-weight:600; color:var(--text); font-size:11px; }
  478. .wf-progress-header .wf-icon { font-size:12px; }
  479. .wf-step { display:flex; align-items:center; gap:6px; padding:2px 0; color:var(--text2); }
  480. .wf-step-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--border); transition:background 0.3s; }
  481. .wf-step-dot.pending { background:var(--border); }
  482. .wf-step-dot.running { background:var(--yellow); animation:wfpulse 1s ease-in-out infinite; }
  483. .wf-step-dot.done { background:var(--green); }
  484. .wf-step-dot.error { background:var(--red); }
  485. .wf-step-dot.paused { background:var(--purple); animation:wfpulse 1.5s ease-in-out infinite; }
  486. .wf-step-dot.skipped { background:var(--text2); opacity:0.4; }
  487. @keyframes wfpulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(1.3)} }
  488. .wf-step.active { color:var(--text); font-weight:500; }
  489. .wf-step.completed { color:var(--text2); opacity:0.6; }
  490. .wf-progress-actions { margin-top:6px; display:flex; gap:4px; }
  491. .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; }
  492. .wf-progress-actions button:hover { background:var(--border); color:var(--text); }
  493. .wf-approve-btn { border-color:var(--green) !important; color:var(--green) !important; }
  494. .wf-approve-btn:hover { background:var(--green) !important; color:#fff !important; }
  495. .wf-cancel-btn:hover { background:var(--red) !important; color:#fff !important; border-color:var(--red) !important; }
  496. .wf-step-type { font-size:8px; color:var(--text2); opacity:0.6; margin-left:auto; }
  497. .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; }
  498. .chat-header:active { cursor:grabbing; }
  499. .chat-messages { flex:1; overflow-y:auto; padding:10px; }
  500. .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; }
  501. .msg.user { background:var(--bg3); border:1px solid var(--border); }
  502. .msg.assistant { background:#161b22; border:1px solid #30363d; }
  503. .msg .label { font-size:9px; color:var(--text2); margin-bottom:3px; text-transform:uppercase; letter-spacing:0.5px; }
  504. .msg .msg-time { font-size:9px; color:var(--text2); opacity:0.7; text-transform:none; letter-spacing:0; margin-left:6px; }
  505. /* Claude Code-style compact tool indicators */
  506. .tool-group { margin:4px 0; background:var(--bg2); border-radius:6px; border:1px solid var(--border); overflow:hidden; }
  507. .tool-header { display:flex; align-items:center; gap:6px; padding:6px 10px; cursor:pointer; font-size:11px; color:var(--text2); }
  508. .tool-header:hover { background:var(--bg3); }
  509. .tool-header .tool-icon { width:16px; text-align:center; font-size:12px; flex-shrink:0; }
  510. .tool-header .tool-name { color:var(--accent); font-weight:600; min-width:50px; }
  511. .tool-header .tool-desc { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); }
  512. .tool-header .tool-time { font-size:9px; color:var(--text2); opacity:0.6; min-width:28px; text-align:right; flex-shrink:0; }
  513. .tool-header .tool-toggle { font-size:8px; color:var(--text2); transition:transform 0.2s; flex-shrink:0; }
  514. .tool-header .tool-toggle.open { transform:rotate(90deg); }
  515. .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; }
  516. .tool-body.open { display:block; }
  517. .tool-detail { font-size:10px; color:var(--text2); padding:2px 0; display:flex; gap:6px; }
  518. .tool-detail .td-label { color:var(--text2); opacity:0.7; min-width:50px; }
  519. .tool-detail .td-val { color:var(--text); flex:1; }
  520. .tool-diff { margin:3px 0; font-family:var(--font); font-size:11px; }
  521. .tool-diff .td-old { color:var(--red); opacity:0.8; padding:1px 4px; background:rgba(248,81,73,0.08); border-radius:2px; }
  522. .tool-diff .td-new { color:var(--green); padding:1px 4px; background:rgba(63,185,80,0.08); border-radius:2px; }
  523. .tool-result-badge { display:inline-block; font-size:9px; padding:1px 6px; border-radius:8px; margin-left:6px; }
  524. .tool-result-badge.ok { background:rgba(63,185,80,0.15); color:var(--green); }
  525. .tool-result-badge.err { background:rgba(248,81,73,0.15); color:var(--red); }
  526. .tool-status-icon { font-size:12px; flex-shrink:0; }
  527. .tool-status-icon.running { color:var(--accent); }
  528. .tool-status-icon.done { color:var(--green); }
  529. .tool-status-icon.error { color:var(--red); }
  530. /* Spinner for active tool */
  531. @keyframes spin { to { transform:rotate(360deg); } }
  532. .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; }
  533. /* Todo list (compact) */
  534. .msg.todo-list { background:var(--bg2); font-size:11px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); }
  535. .todo-item { display:flex; align-items:center; gap:5px; padding:2px 0; }
  536. .todo-icon { width:12px; text-align:center; font-size:10px; }
  537. .todo-done { color:var(--green); }
  538. .todo-active { color:var(--yellow); }
  539. .todo-pending { color:var(--text2); }
  540. .todo-subtask { padding-left:20px; font-size:10px; }
  541. .todo-timing { margin-left:auto; font-size:9px; opacity:0.6; font-variant-numeric:tabular-nums; }
  542. .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; }
  543. .todo-text { flex:1; }
  544. /* Thinking indicator */
  545. .thinking-block { margin:4px 0; padding:6px 10px; background:linear-gradient(135deg, #1a1e2e, #161b22); border-radius:6px; border:1px solid #30365d; font-size:11px; }
  546. .thinking-header { display:flex; align-items:center; gap:6px; color:var(--purple); cursor:pointer; }
  547. .thinking-header .think-icon { animation:pulse 1.5s ease-in-out infinite; }
  548. @keyframes pulse { 0%,100% { opacity:0.5; } 50% { opacity:1; } }
  549. .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; }
  550. .thinking-body.open { display:block; }
  551. .thinking-block.done .think-icon { animation:none; opacity:0.5; }
  552. .thinking-block.done .thinking-header { color:var(--text2); }
  553. /* Markdown in assistant messages */
  554. .msg.assistant .content-text { white-space:normal; }
  555. .msg.assistant .content-text p { margin:4px 0; }
  556. .msg.assistant .content-text code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:11px; }
  557. .msg.assistant .content-text pre { background:var(--bg); border:1px solid var(--border); border-radius:4px; padding:8px; margin:6px 0; overflow-x:auto; }
  558. .msg.assistant .content-text pre code { background:none; padding:0; }
  559. .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); }
  560. .msg.assistant .content-text ul,.msg.assistant .content-text ol { padding-left:18px; margin:4px 0; }
  561. .msg.assistant .content-text li { margin:2px 0; }
  562. .msg.assistant .content-text strong { color:var(--text); }
  563. .msg.assistant .content-text a { color:var(--accent); text-decoration:none; }
  564. .msg.assistant .content-text blockquote { border-left:2px solid var(--border); padding-left:8px; color:var(--text2); margin:4px 0; }
  565. /* Retry indicator */
  566. .retry-msg { margin:4px 0; padding:4px 10px; font-size:10px; color:var(--yellow); display:flex; align-items:center; gap:5px; }
  567. /* Token details in context bar */
  568. .ctx-tooltip { position:relative; cursor:help; }
  569. .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; }
  570. .ctx-tooltip:hover .ctx-detail { display:block; }
  571. .chat-input { display:flex; border-top:1px solid var(--border); background:var(--bg2); }
  572. .chat-input input { flex:1; background:transparent; border:none; color:var(--text); padding:10px 14px; font-family:var(--font); font-size:12px; outline:none; }
  573. .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; }
  574. .chat-input button:disabled { opacity:0.4; cursor:default; }
  575. /* Bottom bar */
  576. .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; }
  577. .bottom-bar .status { display:flex; align-items:center; gap:5px; }
  578. .bottom-bar .dot { width:6px; height:6px; border-radius:50%; }
  579. .dot-green { background:var(--green); }
  580. .dot-yellow { background:var(--yellow); }
  581. .dot-red { background:var(--red); }
  582. /* Modals */
  583. .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; }
  584. .modal-overlay.open { display:flex; }
  585. .modal-box { background:var(--bg2); border:1px solid var(--border); border-radius:10px; width:560px; max-height:80vh; overflow-y:auto; padding:20px; }
  586. .modal-box h2 { margin-bottom:14px; font-size:16px; }
  587. .modal-box label { display:block; font-size:11px; color:var(--text2); margin-bottom:4px; margin-top:12px; }
  588. .modal-box input[type=text], .modal-box input[type=password], .modal-box select, .modal-box textarea {
  589. width:100%; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px;
  590. padding:8px 10px; font-family:var(--font); font-size:12px; outline:none; }
  591. .modal-box input:focus, .modal-box select:focus, .modal-box textarea:focus { border-color:var(--accent); }
  592. .modal-box textarea { height:100px; resize:vertical; }
  593. .modal-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
  594. .modal-actions .hdr-btn { padding:6px 18px; }
  595. /* Settings-specific */
  596. .key-row { display:flex; gap:6px; align-items:center; }
  597. .key-row input { flex:1; }
  598. .key-row button { flex-shrink:0; }
  599. .key-status { font-size:10px; margin-top:3px; }
  600. .key-ok { color:var(--green); }
  601. .key-missing { color:var(--red); }
  602. .model-option { padding:2px 0; }
  603. .model-desc { font-size:10px; color:var(--text2); }
  604. /* Setup overlay (shown when no API key) */
  605. /* Landing / Login page */
  606. .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; }
  607. .landing-overlay.active { display:flex; }
  608. .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; }
  609. .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); }
  610. .landing-brand { display:flex; flex-direction:column; align-items:center; gap:12px; margin-bottom:14px; }
  611. .landing-brand img { width:120px; height:auto; filter:drop-shadow(0 14px 32px rgba(23, 94, 183, 0.28)); }
  612. .landing-box h1 { color:var(--accent); font-size:28px; margin-bottom:4px; }
  613. .landing-box .landing-sub { color:var(--text2); font-size:12px; margin-bottom:20px; line-height:1.5; }
  614. .app-brand { display:flex; align-items:center; gap:10px; }
  615. .app-brand img { width:20px; height:20px; border-radius:6px; }
  616. .landing-box input { width:100%; margin-bottom:8px; }
  617. .landing-box .hdr-btn { width:100%; padding:8px; font-size:12px; }
  618. .landing-section { background:var(--bg2); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; text-align:left; }
  619. .landing-section h3 { font-size:12px; color:var(--text); margin-bottom:10px; display:flex; align-items:center; gap:6px; }
  620. .landing-section h3 .ls-badge { font-size:9px; padding:2px 6px; border-radius:3px; font-weight:400; }
  621. .landing-section h3 .ls-badge.recommended { background:var(--green); color:#fff; }
  622. .landing-section h3 .ls-badge.optional { background:var(--bg3); color:var(--text2); }
  623. .landing-tabs { display:flex; gap:0; margin-bottom:10px; border-bottom:1px solid var(--border); }
  624. .landing-tab { padding:6px 14px; font-size:11px; color:var(--text2); cursor:pointer; border-bottom:2px solid transparent; font-family:var(--font); }
  625. .landing-tab:hover { color:var(--text); }
  626. .landing-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
  627. .landing-tab-panel { display:none; }
  628. .landing-tab-panel.active { display:block; }
  629. .landing-or { text-align:center; color:var(--text2); font-size:10px; margin:8px 0; position:relative; }
  630. .landing-or::before, .landing-or::after { content:''; position:absolute; top:50%; width:40%; height:1px; background:var(--border); }
  631. .landing-or::before { left:0; }
  632. .landing-or::after { right:0; }
  633. .landing-skip { text-align:center; margin-top:8px; }
  634. .landing-skip a { color:var(--text2); font-size:11px; cursor:pointer; text-decoration:underline; }
  635. .landing-skip a:hover { color:var(--accent); }
  636. .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); }
  637. .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); }
  638. .landing-docs-head h2 { font-size:16px; color:var(--text); margin:0 0 4px; }
  639. .landing-docs-copy { font-size:11px; color:var(--text2); line-height:1.6; }
  640. .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; }
  641. .landing-docs-note code { color:var(--accent); }
  642. .landing-docs-frame { flex:1; width:100%; border:0; background:var(--bg); min-height:560px; }
  643. @media (max-width: 1100px) {
  644. .landing-shell { grid-template-columns:1fr; min-height:auto; }
  645. .landing-docs-frame { min-height:460px; }
  646. }
  647. /* LLM provider indicator */
  648. .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; }
  649. .llm-badge.cli { background:#3fb95022; color:var(--green); border:1px solid #3fb95044; }
  650. .llm-badge.apikey { background:#58a6ff22; color:var(--accent); border:1px solid #58a6ff44; }
  651. /* Gen progress */
  652. .gen-progress { margin-top:14px; }
  653. .gen-step { padding:4px 0; display:flex; align-items:center; gap:6px; font-size:11px; }
  654. /* Drag-and-drop overlay */
  655. .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; }
  656. .drop-overlay.active { display:flex; }
  657. .drop-overlay .drop-msg { pointer-events:none; }
  658. .drop-overlay .drop-msg { background:var(--bg2); border:1px solid var(--accent); border-radius:12px; padding:24px 36px; text-align:center; }
  659. .drop-overlay .drop-msg h2 { color:var(--accent); font-size:18px; margin-bottom:6px; }
  660. .drop-overlay .drop-msg p { color:var(--text2); font-size:12px; }
  661. /* Workspace display — current workspace only */
  662. .ws-tabs { display:flex; align-items:center; min-width:0; max-width:260px; flex:0 1 260px; }
  663. .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; }
  664. .ws-current:hover { border-color:#3a4654; background:linear-gradient(180deg, #202a36 0%, #18212b 100%); }
  665. .ws-current.empty { color:var(--text2); }
  666. .ws-current-icon { color:var(--green); font-size:9px; flex-shrink:0; }
  667. .ws-current.empty .ws-current-icon { color:var(--text2); }
  668. .ws-current-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:600; }
  669. .ws-current.ws-btn-highlight { border-color:var(--accent); color:var(--accent); animation:wsBtnPulse 1.5s ease-in-out infinite; }
  670. @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)} }
  671. /* Workspace popover (reuses old dropdown items) */
  672. .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); }
  673. .ws-popover.open { display:block; }
  674. .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); }
  675. .ws-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; gap:6px; }
  676. .ws-item:hover { background:var(--bg3); }
  677. .ws-item.active { background:rgba(88,166,255,0.08); border-left:2px solid var(--accent); }
  678. .ws-item .ws-item-name { font-size:11px; color:var(--text); font-weight:600; }
  679. .ws-item .ws-item-path { font-size:9px; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:200px; }
  680. .ws-item .ws-del { color:var(--red); cursor:pointer; font-size:12px; opacity:0.5; padding:2px 4px; }
  681. .ws-item .ws-del:hover { opacity:1; }
  682. .ws-add { padding:8px 12px; display:flex; gap:6px; align-items:center; }
  683. .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; }
  684. .ws-add input:focus { border-color:var(--accent); }
  685. .ws-add button { font-size:10px; }
  686. /* Directory browser in workspace dropdown */
  687. .ws-browse { border-top:1px solid var(--border); }
  688. .ws-browse-header { display:flex; align-items:center; gap:4px; padding:6px 8px; background:var(--bg); border-bottom:1px solid var(--border); }
  689. .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); }
  690. .ws-browse-header .browse-up:hover { color:var(--text); border-color:var(--text2); }
  691. .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; }
  692. .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; }
  693. .ws-browse-header .browse-select:hover { opacity:0.85; }
  694. .ws-browse-list { max-height:180px; overflow-y:auto; }
  695. .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); }
  696. .ws-browse-item:hover { background:var(--bg3); }
  697. .ws-browse-item.is-vl { background:rgba(88,166,255,0.05); }
  698. .ws-browse-item .dir-icon { font-size:12px; flex-shrink:0; }
  699. .ws-browse-item .dir-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  700. .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; }
  701. /* Chat input area (enhanced with image + mention) */
  702. .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; }
  703. .chat-attachments { display:flex; gap:6px; padding:0 0 6px; flex-wrap:wrap; }
  704. .chat-attachments:empty { display:none; }
  705. .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; }
  706. .chat-attach-item img { width:24px; height:24px; object-fit:cover; border-radius:2px; }
  707. .chat-attach-item .remove { cursor:pointer; color:var(--red); font-size:10px; }
  708. .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; }
  709. .plan-mode-label { color:var(--yellow, #e2b714); font-weight:600; flex:1; }
  710. .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; }
  711. .plan-approve-btn:hover { opacity:0.85; }
  712. .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; }
  713. .plan-cancel-btn:hover { opacity:0.85; }
  714. #planModeToggle.active { color:var(--yellow, #e2b714); }
  715. .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); }
  716. .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); }
  717. .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; }
  718. .chat-input-row textarea::placeholder { color:#778291; }
  719. .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; }
  720. .chat-input-row .input-btn:hover { color:var(--text); background:rgba(255,255,255,0.05); }
  721. .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); }
  722. .chat-input-row button.send-btn:disabled { opacity:0.4; cursor:default; }
  723. .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; }
  724. .chat-input-row button.stop-btn:hover { opacity:0.85; }
  725. .settings-provider-switch { display:flex; gap:8px; margin-bottom:8px; }
  726. .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; }
  727. .settings-provider-option input { margin-top:2px; }
  728. .settings-provider-copy { display:flex; flex-direction:column; gap:2px; }
  729. .settings-provider-copy strong { font-size:11px; color:var(--text); }
  730. .settings-provider-copy span { font-size:10px; color:var(--text2); line-height:1.4; }
  731. .settings-provider-hint { font-size:10px; color:var(--text2); margin:-2px 0 10px; line-height:1.5; }
  732. .settings-doc-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; margin-top:8px; }
  733. .settings-doc-card { display:flex; flex-direction:column; gap:4px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); }
  734. .settings-doc-card .settings-doc-title { font-size:11px; color:var(--text); font-weight:600; }
  735. .settings-doc-card .settings-doc-meta { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.4px; }
  736. .settings-doc-card input { margin-top:2px; }
  737. .settings-doc-hint { font-size:10px; color:var(--text2); line-height:1.5; margin-top:8px; }
  738. .mode-tab[data-mode="docs"] { color:var(--yellow); }
  739. .mode-tab[data-mode="docs"].active { color:var(--yellow); border-bottom-color:var(--yellow); }
  740. .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; }
  741. .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; }
  742. @keyframes csPulse { 0%,100%{opacity:.4;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
  743. .chat-status-bar .cs-phase { color:var(--text); font-weight:500; }
  744. .chat-status-bar .cs-detail { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px; }
  745. .chat-status-bar .cs-elapsed { margin-left:auto; color:var(--text2); }
  746. .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; }
  747. .chat-status-bar .cs-kill:hover { opacity:0.8; }
  748. /* @-mention autocomplete dropdown */
  749. .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; }
  750. .mention-dropdown.open { display:block; }
  751. .mention-item { padding:5px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:6px; }
  752. .mention-item:hover,.mention-item.selected { background:var(--bg3); }
  753. .mention-item .m-type { font-size:8px; padding:1px 3px; border-radius:2px; font-weight:600; }
  754. /* Apply button for code blocks */
  755. .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; }
  756. .code-apply:hover { opacity:1; }
  757. .msg.assistant .content-text pre { position:relative; }
  758. /* Inline Diff */
  759. .diff-block { margin:6px 0; border:1px solid var(--border); border-radius:6px; overflow:hidden; font-size:11px; }
  760. .diff-header { display:flex; justify-content:space-between; align-items:center; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
  761. .diff-header .diff-file { color:var(--accent); font-weight:600; }
  762. .diff-actions { display:flex; gap:4px; }
  763. .diff-actions button { padding:2px 8px; border-radius:3px; font-size:9px; cursor:pointer; font-family:var(--font); border:none; }
  764. .diff-accept { background:var(--green); color:#fff; }
  765. .diff-reject { background:var(--red); color:#fff; }
  766. .diff-body { max-height:200px; overflow-y:auto; }
  767. .diff-line { padding:0 8px; font-family:var(--font); white-space:pre; }
  768. .diff-add { background:#3fb95018; color:var(--green); }
  769. .diff-add::before { content:'+'; margin-right:6px; }
  770. .diff-del { background:#f8514918; color:var(--red); text-decoration:line-through; }
  771. .diff-del::before { content:'-'; margin-right:6px; }
  772. .diff-ctx { color:var(--text2); }
  773. /* Conversation tabs */
  774. .conv-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:28px; align-items:center; position:relative; }
  775. .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; }
  776. .conv-tab.active { color:var(--text); border-bottom-color:var(--accent); }
  777. .conv-tab .conv-close { font-size:8px; opacity:0.5; cursor:pointer; margin-left:4px; }
  778. .conv-tab .conv-close:hover { opacity:1; color:var(--red); }
  779. .conv-new { padding:5px 8px; cursor:pointer; color:var(--text2); font-size:12px; border:none; background:none; }
  780. .conv-new:hover { color:var(--accent); }
  781. /* History dropdown */
  782. .conv-tabs .tab-spacer { flex:1; min-width:8px; }
  783. .conv-history-btn { padding:4px 8px; cursor:pointer; color:var(--text2); font-size:10px; border:none; background:none; opacity:0.7; }
  784. .conv-history-btn:hover { opacity:1; color:var(--accent); }
  785. .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; }
  786. .history-panel.open { display:flex; }
  787. .history-search { padding:8px; border-bottom:1px solid var(--border); }
  788. .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; }
  789. .history-search input:focus { border-color:var(--accent); }
  790. .history-list { flex:1; overflow-y:auto; padding:4px 0; }
  791. .history-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); }
  792. .history-item:hover { background:var(--bg3); }
  793. .history-item:last-child { border-bottom:none; }
  794. .history-item .hi-title { font-size:11px; color:var(--text); font-weight:500; margin-bottom:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  795. .history-item .hi-meta { display:flex; align-items:center; gap:6px; font-size:9px; color:var(--text2); }
  796. .history-item .hi-tag { background:var(--bg3); border:1px solid var(--border); border-radius:3px; padding:1px 5px; font-size:8px; color:var(--accent); }
  797. .history-item .hi-summary { font-size:10px; color:var(--text2); margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  798. .history-empty { padding:20px; text-align:center; color:var(--text2); font-size:11px; }
  799. /* Image in user message */
  800. .msg-images { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
  801. .msg-images img { max-width:120px; max-height:80px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
  802. /* Auto-screenshots from tests */
  803. .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); }
  804. .msg-screenshots .ss-item { position:relative; }
  805. .msg-screenshots img { max-width:200px; max-height:140px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
  806. .msg-screenshots img:hover { border-color:var(--accent); }
  807. .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; }
  808. .debug-entry .debug-screenshots { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
  809. .debug-entry .debug-screenshots img { max-width:160px; max-height:100px; border-radius:3px; border:1px solid var(--border); cursor:pointer; }
  810. /* AskUserQuestion widget */
  811. .ask-user-widget { margin:6px 0; background:var(--bg2); border:1px solid var(--accent); border-radius:8px; padding:10px 14px; }
  812. .ask-user-widget .ask-question { color:var(--text); font-size:12px; font-weight:600; margin-bottom:8px; }
  813. .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; }
  814. .ask-user-option:hover { border-color:var(--accent); background:rgba(88,166,255,0.08); }
  815. .ask-user-option.selected { border-color:var(--accent); background:rgba(88,166,255,0.15); }
  816. .ask-user-option input[type=radio],.ask-user-option input[type=checkbox] { margin-top:3px; accent-color:var(--accent); }
  817. .ask-user-option .opt-label { font-size:11px; color:var(--text); font-weight:600; }
  818. .ask-user-option .opt-desc { font-size:10px; color:var(--text2); margin-top:1px; }
  819. .ask-user-other { margin-top:6px; display:flex; gap:6px; }
  820. .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; }
  821. .ask-user-submit { margin-top:8px; display:flex; justify-content:flex-end; }
  822. .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; }
  823. /* Skill command palette */
  824. .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; }
  825. .skill-palette.open { display:block; }
  826. .skill-item { padding:6px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:8px; }
  827. .skill-item:hover,.skill-item.selected { background:var(--bg3); }
  828. .skill-item .sk-name { color:var(--accent); font-weight:600; }
  829. .skill-item .sk-desc { color:var(--text2); font-size:10px; }
  830. /* Search bar in chat */
  831. .chat-search { display:none; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
  832. .chat-search.open { display:flex; gap:6px; align-items:center; }
  833. .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; }
  834. .chat-search .search-count { font-size:10px; color:var(--text2); }
  835. /* Scrollbar */
  836. ::-webkit-scrollbar { width:5px; }
  837. ::-webkit-scrollbar-track { background:transparent; }
  838. ::-webkit-scrollbar-thumb { background:var(--bg3); border-radius:3px; }
  839. </style>
  840. </head>
  841. <body>
  842. <!-- Landing / Login page (shown before entering IDE) -->
  843. <div class="landing-overlay" id="landingOverlay">
  844. <div class="landing-shell">
  845. <div class="landing-box">
  846. <div class="landing-brand">
  847. <img src="/assets/vlcode-lite-icon.svg?v=20260315" alt="VL-Code logo">
  848. <div>
  849. <h1>VL-Code</h1>
  850. <div class="landing-sub">AI Programming IDE for VL Language &middot; Powered by Claude</div>
  851. </div>
  852. </div>
  853. <!-- Section 1: VL Cloud Login -->
  854. <div class="landing-section">
  855. <h3>&#9729; VL Cloud Platform <span class="ls-badge recommended">Recommended</span></h3>
  856. <div class="landing-tabs">
  857. <div class="landing-tab active" data-ltab="enterprise" onclick="switchLandingTab('enterprise')">Enterprise</div>
  858. <div class="landing-tab" data-ltab="google" onclick="switchLandingTab('google')">Google</div>
  859. <div class="landing-tab" data-ltab="token" onclick="switchLandingTab('token')">Token</div>
  860. </div>
  861. <!-- Enterprise -->
  862. <div class="landing-tab-panel active" id="ltEnterprise">
  863. <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;">
  864. <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;">
  865. <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;">
  866. <button class="hdr-btn hdr-btn-primary" onclick="doLandingEnterpriseLogin()" style="margin-top:4px;">Login &amp; Enter IDE</button>
  867. <div id="landingLoginError" style="display:none;color:var(--red);font-size:10px;margin-top:6px;"></div>
  868. </div>
  869. <!-- Google -->
  870. <div class="landing-tab-panel" id="ltGoogle">
  871. <div style="text-align:center;padding:8px 0;">
  872. <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Google Login in Browser</button>
  873. <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
  874. After Google login, copy your <code style="color:var(--accent)">ih5bearer</code> cookie<br>and paste it in the Token tab.
  875. </div>
  876. </div>
  877. </div>
  878. <!-- Token -->
  879. <div class="landing-tab-panel" id="ltToken">
  880. <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;">
  881. <button class="hdr-btn hdr-btn-primary" onclick="doLandingTokenLogin()" style="margin-top:4px;">Connect &amp; Enter IDE</button>
  882. </div>
  883. </div>
  884. <!-- Section 2: Claude API Key (optional) -->
  885. <div class="landing-section">
  886. <h3>&#129302; Claude API Key <span class="ls-badge optional">Optional</span></h3>
  887. <div style="font-size:10px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
  888. 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>
  889. Only fill this if you don't have a CLI subscription.
  890. </div>
  891. <div id="landingCliStatus" style="font-size:11px;margin-bottom:8px;display:none;"></div>
  892. <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;">
  893. </div>
  894. <!-- Enter IDE -->
  895. <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>
  896. <div class="landing-skip">
  897. <a onclick="enterIDE()">Skip login, enter IDE directly</a>
  898. </div>
  899. <div style="font-size:9px;color:var(--text2);margin-top:12px;">Settings are saved locally and never shared.</div>
  900. </div>
  901. <div class="landing-docs">
  902. <div class="landing-docs-head">
  903. <div>
  904. <h2>Official DocCenter</h2>
  905. <div class="landing-docs-copy">Search official docs, copy the stable <code>Doc ID</code>, then fill it into VLCode's settings after login.</div>
  906. </div>
  907. <button class="hdr-btn" onclick="refreshLandingDocsFrame()">Refresh</button>
  908. </div>
  909. <div class="landing-docs-note">Workflow execution resolves official specs and workflow prompts by <code>Doc ID</code> first. Paths stay as reserved aliases; IDs are the runtime source of truth.</div>
  910. <iframe id="landingDocsFrame" class="landing-docs-frame" src="/doc-center.html?embed=landing" title="VLCode DocCenter"></iframe>
  911. </div>
  912. </div>
  913. </div>
  914. <header>
  915. <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>
  916. <div class="ws-tabs" id="wsTabs"></div>
  917. <div style="position:relative;">
  918. <div class="ws-popover" id="wsPopover" style="min-width:340px;">
  919. <div class="ws-section" style="display:flex;justify-content:space-between;align-items:center;">
  920. <span>Workspaces</span>
  921. <div style="display:flex;gap:4px;">
  922. <button class="hdr-btn" id="wsOpenFolderBtn" onclick="event.stopPropagation();openWorkspacePicker()" style="display:none;font-size:9px;padding:2px 8px;">Open Folder...</button>
  923. <button class="hdr-btn" onclick="event.stopPropagation();closeWorkspace()" style="font-size:9px;padding:2px 8px;color:var(--text2);">Close</button>
  924. <button class="hdr-btn" onclick="event.stopPropagation();toggleNewProjectForm()" style="font-size:9px;padding:2px 8px;">+ New Project</button>
  925. </div>
  926. </div>
  927. <div id="wsNewProjectForm" style="display:none;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg2);">
  928. <div style="font-size:10px;color:var(--text2);margin-bottom:6px;">Create New VL Project</div>
  929. <div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;">
  930. <span style="font-size:9px;color:var(--text2);white-space:nowrap;">Location:</span>
  931. <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;">
  932. <button class="hdr-btn" id="newProjectLocationPickBtn" onclick="event.stopPropagation();pickNewProjectLocation()" style="display:none;font-size:9px;padding:3px 8px;">Pick</button>
  933. </div>
  934. <div style="display:flex;gap:4px;">
  935. <input type="text" id="newProjectName" placeholder="Project name..." style="flex:1;font-size:11px;padding:4px 6px;" onkeydown="if(event.key==='Enter')createNewProject()">
  936. <button class="hdr-btn hdr-btn-primary" onclick="createNewProject()" style="font-size:10px;padding:4px 10px;">Create</button>
  937. </div>
  938. <div id="newProjectError" style="font-size:9px;color:var(--red);margin-top:4px;display:none;"></div>
  939. </div>
  940. <div id="wsList"></div>
  941. <div class="ws-browse">
  942. <div class="ws-browse-header">
  943. <button class="browse-up" onclick="event.stopPropagation();browseDirUp()" title="Go to parent directory">&#9650;</button>
  944. <span class="browse-path" id="browsePath">~</span>
  945. <button class="browse-select" onclick="event.stopPropagation();selectBrowseDir()" title="Open this directory as workspace">Select</button>
  946. </div>
  947. <div class="ws-browse-list" id="browseList"></div>
  948. </div>
  949. <div class="ws-add">
  950. <input type="text" id="wsAddPath" placeholder="Or type path..." onkeydown="if(event.key==='Enter')addWorkspace()">
  951. <button class="hdr-btn" onclick="addWorkspace()" style="font-size:10px;padding:3px 8px;">Go</button>
  952. </div>
  953. </div>
  954. </div>
  955. <div class="spacer"></div>
  956. <span class="info" id="projectInfo" style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
  957. <div class="ctx-bar ctx-tooltip">
  958. <span class="info" id="ctxLabel">0%</span>
  959. <div class="bar"><div class="bar-fill" id="ctxBar" style="width:0%"></div></div>
  960. <div class="ctx-detail" id="ctxDetail">Context: 0 / 200K tokens</div>
  961. </div>
  962. <div class="wf-selector" style="position:relative;display:none;">
  963. <button class="hdr-btn" onclick="toggleWorkflowPanel()" id="wfSelectorBtn" title="Select codegen workflow">
  964. <span id="wfSelectorLabel">Parallel</span> <span style="font-size:9px;opacity:0.6">&#9660;</span>
  965. </button>
  966. <div class="wf-dropdown" id="wfDropdown">
  967. <div style="font-size:10px;color:var(--text2);padding:8px 10px;border-bottom:1px solid var(--border);">Codegen Workflow</div>
  968. <div id="wfCodegenOptions"></div>
  969. <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>
  970. <div id="wfAdjustOptions"></div>
  971. <div style="font-size:9px;color:var(--text2);padding:6px 10px;border-top:1px solid var(--border);">
  972. <div style="cursor:pointer;" onclick="toggleWfAllList()">All workflows <span id="wfAllToggle">&#9654;</span></div>
  973. </div>
  974. <div id="wfList" style="max-height:120px;overflow-y:auto;display:none;"></div>
  975. </div>
  976. </div>
  977. <button class="hdr-btn" id="compileBtn" onclick="compileProject()" title="Compile & Preview">&#9654; Compile</button>
  978. <!-- Mode toggle removed (VLCode Lite — no fleet management) -->
  979. <span class="llm-badge cli" id="llmBadge" title="LLM Provider" onclick="openSettings()">CLI</span>
  980. <div class="auth-status" id="authStatus" onclick="onAuthStatusClick()" title="Cloud Platform Account">
  981. <span class="auth-dot" id="authDot"></span>
  982. <span class="auth-label" id="authLabel">Not logged in</span>
  983. </div>
  984. <button class="hdr-btn" onclick="switchMode('docs')" title="Documentation">Docs</button>
  985. <button class="hdr-btn" id="cloudBtn" onclick="toggleCloudPanel()" title="Cloud Platform">&#9729; Cloud</button>
  986. <button class="hdr-btn" onclick="restartBackend()" title="Restart Backend" id="restartBtn">&#8635;</button>
  987. <button class="hdr-btn" onclick="openSettings()" title="Settings">&#9881;</button>
  988. </header>
  989. <main>
  990. <div class="sidebar">
  991. <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>
  992. <div class="sidebar-actions">
  993. <button class="sa-btn" onclick="importFiles()" title="Import files into project"><span class="sa-icon">+</span>Import</button>
  994. <button class="sa-btn" onclick="importZipAsProject()" title="Create new project from ZIP"><span class="sa-icon">&#9634;</span>ZIP</button>
  995. <button class="sa-btn" onclick="exportAll()" title="Export project with all files"><span class="sa-icon">&#8615;</span>Export</button>
  996. <button class="sa-btn" onclick="exportVLOnly()" title="Export VL files only"><span class="sa-icon">&#8615;</span>VL</button>
  997. <button class="sa-btn" id="toggleInternalFilesBtn" onclick="toggleInternalFiles()" title="Show internal files and generated artifacts"><span class="sa-icon">&#8942;</span>Internal</button>
  998. <button class="sa-btn sa-danger" onclick="clearAllFiles()" title="Remove all files"><span class="sa-icon">&times;</span>Clear</button>
  999. </div>
  1000. <div class="file-tree" id="fileTree"
  1001. ondragover="handleFileTreeDragOver(event)"
  1002. ondragleave="handleFileTreeDragLeave(event)"
  1003. ondrop="handleFileTreeDrop(event)">
  1004. </div>
  1005. <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;">
  1006. <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>
  1007. </div>
  1008. <div class="project-config" id="projectConfigPanel">
  1009. <h4 class="pc-header" onclick="$('pcFiles').style.display=$('pcFiles').style.display==='none'?'block':'none'">Project <span style="float:right;font-size:8px;">&#9660;</span></h4>
  1010. <div class="pc-files" id="pcFiles">
  1011. <div class="pc-file" onclick="openFile('.vl-code/VL.md')" title="Project instructions for AI">VL.md</div>
  1012. </div>
  1013. </div>
  1014. <div class="project-config" id="docIdConfigPanel">
  1015. <h4 class="pc-header" onclick="toggleDocIdConfigPanel()" style="display:flex;justify-content:space-between;align-items:center;">
  1016. Official Doc IDs
  1017. <span style="display:flex;gap:4px;align-items:center;">
  1018. <button class="pc-sync-btn" onclick="event.stopPropagation();switchMode('docs')" title="Open embedded DocCenter">Docs</button>
  1019. <button class="pc-sync-btn" onclick="event.stopPropagation();saveDocIdConfigPanel()" title="Save document IDs">Save</button>
  1020. <span style="font-size:8px;">&#9660;</span>
  1021. </span>
  1022. </h4>
  1023. <div id="docIdConfigBody">
  1024. <div class="doc-id-panel-note">Path 是文档类别,真正执行引用的是 Doc ID。官方文档默认小于 1000,用户文档建议从 1000 以后开始。</div>
  1025. <div class="doc-id-section-title">Core Runtime</div>
  1026. <div class="doc-id-grid" id="docIdCoreGrid"></div>
  1027. <div class="doc-id-section-title doc-id-section-toggle" onclick="toggleDocWorkflowGrid()">
  1028. <span>Workflow Docs</span>
  1029. <span id="docWorkflowToggle">&#9654;</span>
  1030. </div>
  1031. <div class="doc-id-grid" id="docIdWorkflowGrid" style="display:none;"></div>
  1032. <div class="doc-id-section-title">Locked By Tooling</div>
  1033. <div class="doc-id-grid" id="docIdLockedGrid"></div>
  1034. </div>
  1035. </div>
  1036. <div class="project-config" id="vlDocsPanel">
  1037. <h4 class="pc-header" onclick="$('vlDocsList').style.display=$('vlDocsList').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
  1038. VL Reference Docs
  1039. <span style="display:flex;gap:4px;align-items:center;">
  1040. <button class="pc-sync-btn" onclick="event.stopPropagation();syncVLDocs()" title="Sync docs from DocCenter">&#8635;</button>
  1041. <span style="font-size:8px;">&#9660;</span>
  1042. </span>
  1043. </h4>
  1044. <div class="pc-files" id="vlDocsList" style="display:none;"></div>
  1045. </div>
  1046. <div class="preview-urls" id="previewUrlsPanel" style="display:none">
  1047. <h4>App Previews</h4>
  1048. <div id="previewUrlsList"></div>
  1049. </div>
  1050. <div class="project-config" id="cloudPanel" style="display:none;">
  1051. <h4 class="pc-header" onclick="$('cloudPanelBody').style.display=$('cloudPanelBody').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
  1052. Cloud Platform
  1053. <span style="display:flex;gap:4px;align-items:center;">
  1054. <span class="cloud-dot" id="cloudDot"></span>
  1055. <span style="font-size:8px;">&#9660;</span>
  1056. </span>
  1057. </h4>
  1058. <div id="cloudPanelBody">
  1059. <div id="cloudLoginPrompt" class="cloud-section">
  1060. <div style="padding:6px 12px;font-size:10px;color:var(--text2);">Not connected</div>
  1061. <button class="sa-btn" onclick="openCloudLogin()" style="margin:0 12px 8px;width:calc(100% - 24px);">Login</button>
  1062. </div>
  1063. <div id="cloudConnected" class="cloud-section" style="display:none;">
  1064. <div class="cloud-user" id="cloudUserInfo"></div>
  1065. <div class="cloud-actions">
  1066. <button class="sa-btn" onclick="cloudSyncPush()" title="Push local files to cloud workspace">Push</button>
  1067. <button class="sa-btn" onclick="cloudSyncPull()" title="Pull cloud files to local">Pull</button>
  1068. <button class="sa-btn" onclick="cloudCompile()" title="Sync + Compile via cloud workspace">Compile</button>
  1069. </div>
  1070. <div class="cloud-gid">
  1071. <div style="display:flex;align-items:center;justify-content:space-between;padding:0 12px;margin-bottom:2px;">
  1072. <label style="font-size:9px;color:var(--text2);">Workspace GID</label>
  1073. <button class="sa-btn" onclick="createCloudProject()" style="font-size:8px;padding:1px 6px;" title="Create a new cloud workspace and get GID">+ New</button>
  1074. </div>
  1075. <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;">
  1076. </div>
  1077. <div id="cloudAppsList" style="max-height:120px;overflow-y:auto;"></div>
  1078. <button class="sa-btn sa-small" onclick="cloudLogout()" style="margin:4px 12px 8px;font-size:9px;color:var(--red);">Logout</button>
  1079. </div>
  1080. <div class="cloud-status" id="cloudSyncStatus" style="display:none;"></div>
  1081. </div>
  1082. </div>
  1083. </div>
  1084. <div class="content">
  1085. <div class="panels">
  1086. <div class="editor-panel">
  1087. <div class="mode-tabs" id="modeTabs">
  1088. <div class="mode-tab active" data-mode="code" onclick="switchMode('code')">Code</div>
  1089. <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>
  1090. <div class="mode-tab" data-mode="flow" onclick="switchMode('flow')">Flow</div>
  1091. <div class="mode-tab" data-mode="docs" onclick="switchMode('docs')">Docs</div>
  1092. <div class="mode-tab" data-mode="preview" id="previewModeTab" onclick="switchMode('preview')" style="display:none">Preview</div>
  1093. </div>
  1094. <div class="preview-bar" id="previewBar" style="display:none;">
  1095. <select id="previewAppSelect" onchange="loadPreviewApp()"></select>
  1096. <span class="preview-url" id="previewUrlLabel"></span>
  1097. <button class="preview-btn" onclick="refreshPreview()">Refresh</button>
  1098. <button class="preview-btn" onclick="openPreviewExternal()">Open External</button>
  1099. </div>
  1100. <div class="flow-toolbar" id="flowToolbar" style="display:none;">
  1101. <div class="flow-sub-tabs">
  1102. <div class="flow-sub-tab active" data-flow="generate" onclick="switchFlowTab('generate')">Generate</div>
  1103. <div class="flow-sub-tab" data-flow="adjust" onclick="switchFlowTab('adjust')">Adjust</div>
  1104. <div class="flow-sub-tab" data-flow="autotest" onclick="switchFlowTab('autotest')" style="color:var(--orange)">Autotest</div>
  1105. </div>
  1106. <div class="flow-actions">
  1107. <select id="flowWfSelect" onchange="onFlowWfSelectChange(this.value)" title="Select workflow">
  1108. <option value="">-- Select Workflow --</option>
  1109. </select>
  1110. <button class="flow-btn" onclick="importFlowJson()" title="Load workflow JSON from file">Load JSON</button>
  1111. <input type="file" id="flowJsonInput" accept=".json" style="display:none">
  1112. <button class="flow-btn flow-btn-run" id="flowRunBtn" onclick="runFlowWorkflow()" title="Execute selected workflow">&#9654; Run</button>
  1113. <span class="flow-run-status" id="flowRunStatus"></span>
  1114. </div>
  1115. </div>
  1116. <!-- Workflow picker for Generate / Adjust sub-tabs -->
  1117. <div class="flow-wf-list" id="flowWfList"></div>
  1118. <!-- AutoTest 3-layer workflow hierarchy (shown only in autotest tab) -->
  1119. <div class="at-wf-list" id="atWfList"></div>
  1120. <div class="editor-tabs" id="editorTabs"></div>
  1121. <div class="editor-area">
  1122. <div id="cmEditorWrap" style="display:none;width:100%;height:100%;"></div>
  1123. <textarea id="editor" spellcheck="false" style="display:none"></textarea>
  1124. <div class="code-preview" id="codePreview"><pre></pre></div>
  1125. <div class="md-preview" id="mdPreview"></div>
  1126. <div class="iframe-container" id="iframeContainer"></div>
  1127. <div class="editor-placeholder" id="editorPlaceholder">
  1128. Click a file in the tree to view &amp; edit<br>
  1129. <span style="font-size:11px;color:var(--text2)">Use Load button to open a project folder</span>
  1130. </div>
  1131. </div>
  1132. </div>
  1133. <!-- Detail Panel — detailed logs without consuming AI context -->
  1134. <div class="detail-panel" id="detailPanel">
  1135. <div class="detail-header" id="detailHeader">
  1136. <span class="dh-title">Detail Log</span>
  1137. <div style="display:flex;gap:4px;align-items:center;">
  1138. <span id="detailCount" style="font-size:8px;color:var(--text2);"></span>
  1139. <button onclick="clearDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:10px;" title="Clear">&times; Clear</button>
  1140. <button onclick="toggleDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:12px;" title="Close">&times;</button>
  1141. </div>
  1142. </div>
  1143. <div class="detail-body" id="detailBody"></div>
  1144. </div>
  1145. <!-- Debug panel removed — replaced by Detail Log -->
  1146. <div class="chat-panel" id="chatPanel">
  1147. <div class="chat-resize-handle" id="chatResizeHandle"></div>
  1148. <div class="chat-header" onclick="if(document.querySelector('.chat-panel').classList.contains('collapsed'))toggleChatCollapse()">
  1149. <span>AI Assistant</span>
  1150. <div style="display:flex;align-items:center;gap:6px;">
  1151. <span id="chatModel"></span>
  1152. <button class="chat-collapse-btn" onclick="event.stopPropagation();toggleChatCollapse()" title="Collapse/Expand chat">&#9664;</button>
  1153. </div>
  1154. </div>
  1155. <div class="chat-actions" id="chatActions">
  1156. <div class="chat-action-group">
  1157. <button class="ca-btn ca-primary" onclick="sendSkillCmd('validate-all')">Validate</button>
  1158. <button class="ca-btn ca-primary" onclick="sendSkillCmd('deploy')">Deploy</button>
  1159. <button class="ca-btn ca-log" onclick="toggleDetailPanel()" title="Toggle Detail Log panel">Logs</button>
  1160. </div>
  1161. <div class="chat-action-group">
  1162. <div class="ca-menu" id="chatMoreMenu">
  1163. <button class="ca-btn" id="chatMoreBtn" onclick="toggleChatMoreMenu(event)">More &#9662;</button>
  1164. <div class="ca-menu-panel">
  1165. <button class="ca-menu-item menu-accent" onclick="chatMenuAction('blueprint')">Blueprint</button>
  1166. <button class="ca-menu-item" onclick="chatMenuAction('search')">Search Chat</button>
  1167. <button class="ca-menu-item" id="compactMenuItem" onclick="chatMenuAction('compact')">Compact Mode</button>
  1168. <button class="ca-menu-item menu-log" onclick="chatMenuAction('settings')">Settings</button>
  1169. </div>
  1170. </div>
  1171. </div>
  1172. </div>
  1173. <div class="conv-tabs" id="convTabs">
  1174. <div class="conv-tab active" data-conv="0">Chat 1</div>
  1175. <button class="conv-new" onclick="newConversation()" title="New conversation">+</button>
  1176. </div>
  1177. <div class="chat-search" id="chatSearch">
  1178. <input id="chatSearchInput" placeholder="Search conversation..." oninput="searchConversation(this.value)">
  1179. <span class="search-count" id="searchCount"></span>
  1180. <button class="input-btn" onclick="closeChatSearch()" style="font-size:12px">&times;</button>
  1181. </div>
  1182. <div class="chat-messages" id="chatMessages"></div>
  1183. <div class="chat-input-area" style="position:relative;">
  1184. <div class="skill-palette" id="skillPalette"></div>
  1185. <div class="mention-dropdown" id="mentionDropdown"></div>
  1186. <div class="chat-attachments" id="chatAttachments"></div>
  1187. <div class="chat-status-bar" id="chatStatusBar" style="display:none">
  1188. <span class="cs-dot"></span>
  1189. <span class="cs-phase" id="csPhase"></span>
  1190. <span class="cs-detail" id="csDetail"></span>
  1191. <span class="cs-elapsed" id="csElapsed"></span>
  1192. <button class="cs-kill" onclick="stopExecution()" title="Kill all running tasks">STOP</button>
  1193. </div>
  1194. <div class="plan-mode-bar" id="planModeBar" style="display:none">
  1195. <span class="plan-mode-label">&#128270; Explore Mode (read-only)</span>
  1196. <button class="plan-approve-btn" id="planApproveBtn" onclick="approvePlan()" style="display:none">&#10003; Approve</button>
  1197. <button class="plan-cancel-btn" id="planCancelBtn" onclick="cancelPlan()">&#10007; Cancel</button>
  1198. </div>
  1199. <div class="chat-input-row">
  1200. <button class="input-btn" onclick="$('imageInput').click()" title="Attach image">&#128247;</button>
  1201. <button class="input-btn" id="planModeToggle" onclick="togglePlanMode()" title="Toggle Plan Mode (explore before implement)">&#128270;</button>
  1202. <textarea id="chatInput" rows="1" placeholder="Describe changes, @mention files, /skill..." autocomplete="off"></textarea>
  1203. <button class="send-btn" id="chatSend" onclick="sendMessage()">Send</button>
  1204. <button class="stop-btn" id="chatStop" onclick="stopExecution()" style="display:none">Stop</button>
  1205. </div>
  1206. </div>
  1207. <input type="file" id="imageInput" accept="image/*" multiple style="display:none">
  1208. </div>
  1209. </div>
  1210. </div>
  1211. </main>
  1212. <div class="bottom-bar">
  1213. <div class="status"><div class="dot dot-green"></div> <span id="statusText">Ready</span></div>
  1214. <span id="fileCount"></span>
  1215. <span id="currentFile"></span>
  1216. <span style="margin-left:auto" id="modelLabel"></span>
  1217. </div>
  1218. <!-- Drop overlay -->
  1219. <div class="drop-overlay" id="dropOverlay" onclick="this.classList.remove('active');dragCounter=0;">
  1220. <div class="drop-msg">
  1221. <h2>Drop Files or Folder</h2>
  1222. <p>Code files, VL files, JSON, ZIP — preserves folder structure</p>
  1223. </div>
  1224. </div>
  1225. <input type="file" id="folderInput" webkitdirectory directory multiple style="display:none">
  1226. <input type="file" id="zipInput" accept=".zip" style="display:none">
  1227. <!-- File context menu -->
  1228. <div class="ctx-menu" id="fileCtxMenu">
  1229. <div class="ctx-menu-item" onclick="ctxOpenFile()">Open</div>
  1230. <div class="ctx-menu-sep"></div>
  1231. <div class="ctx-menu-item danger" onclick="ctxDeleteFile()">Delete File</div>
  1232. </div>
  1233. <!-- Step card context menu -->
  1234. <div class="step-ctx-menu" id="stepCtxMenu">
  1235. <div class="step-ctx-item" onclick="stepCtxRerun()"><span class="sci-icon">🔄</span><span class="sci-label">Re-run from here</span></div>
  1236. <div class="step-ctx-item" onclick="stepCtxViewInDAG()"><span class="sci-icon">🔍</span><span class="sci-label">Highlight in DAG</span></div>
  1237. <div class="step-ctx-sep"></div>
  1238. <div class="step-ctx-item" onclick="stepCtxCopyOutputs()"><span class="sci-icon">📋</span><span class="sci-label">Copy outputs</span></div>
  1239. <div class="step-ctx-item" onclick="stepCtxCopyFiles()"><span class="sci-icon">📄</span><span class="sci-label">Copy file list</span></div>
  1240. <div class="step-ctx-sep"></div>
  1241. <div class="step-ctx-item" onclick="stepCtxToggleBody()"><span class="sci-icon">📂</span><span class="sci-label">Toggle details</span></div>
  1242. <div class="step-ctx-item" onclick="stepCtxExpandAll()"><span class="sci-icon">⬇</span><span class="sci-label">Expand all sections</span></div>
  1243. <div class="step-ctx-item" onclick="stepCtxCollapseAll()"><span class="sci-icon">⬆</span><span class="sci-label">Collapse all sections</span></div>
  1244. </div>
  1245. <!-- Settings Modal -->
  1246. <div class="modal-overlay" id="settingsModal">
  1247. <div class="modal-box">
  1248. <h2>Settings</h2>
  1249. <label>Claude LLM Provider</label>
  1250. <div class="key-status" id="keyStatus" style="margin-bottom:8px;"></div>
  1251. <div class="settings-provider-switch">
  1252. <label class="settings-provider-option">
  1253. <input type="radio" name="settingsProvider" id="settingsProviderCli" value="cli">
  1254. <span class="settings-provider-copy">
  1255. <strong>CLI</strong>
  1256. <span>Default for daily use. Lower cost, works well when Claude CLI is installed.</span>
  1257. </span>
  1258. </label>
  1259. <label class="settings-provider-option">
  1260. <input type="radio" name="settingsProvider" id="settingsProviderApiKey" value="api-key">
  1261. <span class="settings-provider-copy">
  1262. <strong>API Key</strong>
  1263. <span>Use Anthropic API directly when you want full cloud-only execution.</span>
  1264. </span>
  1265. </label>
  1266. </div>
  1267. <div class="settings-provider-hint" id="settingsProviderHint"></div>
  1268. <label>API Key <span style="font-size:9px;color:var(--text2);">(optional if CLI subscription active)</span></label>
  1269. <div class="key-row">
  1270. <input type="password" id="settingsKey" placeholder="sk-ant-api03-...">
  1271. <button class="hdr-btn" onclick="toggleKeyVisibility()">Show</button>
  1272. </div>
  1273. <label>Model</label>
  1274. <select id="settingsModel">
  1275. <option value="claude-opus-4-6">Claude Opus 4.6 (Most capable)</option>
  1276. <option value="claude-sonnet-4-6">Claude Sonnet 4.6 (Faster)</option>
  1277. <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 (Fastest)</option>
  1278. </select>
  1279. <label>Max Output Tokens</label>
  1280. <input type="number" id="settingsMaxTokens" placeholder="32000" value="32000">
  1281. <label>VL Platform <span style="font-size:9px;color:var(--text2)">(use Cloud button to login)</span></label>
  1282. <div style="display:flex;align-items:center;gap:6px;">
  1283. <span id="settingsCloudStatus" style="font-size:10px;color:var(--text2);">Not connected</span>
  1284. <button class="hdr-btn" onclick="closeSettings();toggleCloudPanel();openCloudLogin();" style="font-size:9px;">Login</button>
  1285. </div>
  1286. <input type="hidden" id="settingsCookie">
  1287. <label>Working Directory</label>
  1288. <input type="text" id="settingsWorkDir" disabled style="opacity:0.6">
  1289. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1290. <label>Official Doc IDs</label>
  1291. <div class="settings-doc-hint">`VL / Theme / workflow docs` 可改;`Meta Spec / Workflow Spec` 只读。后端会优先按这些 Doc ID 取文档。</div>
  1292. <div class="settings-doc-grid" id="settingsDocIdCoreGrid"></div>
  1293. <div class="settings-doc-hint">Workflow Docs</div>
  1294. <div class="settings-doc-grid" id="settingsDocIdWorkflowGrid"></div>
  1295. <div class="settings-doc-hint">Locked By Tooling</div>
  1296. <div class="settings-doc-grid" id="settingsDocIdLockedGrid"></div>
  1297. </div>
  1298. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1299. <label>AutoTest</label>
  1300. <div style="display:flex;flex-direction:column;gap:6px;">
  1301. <label style="font-size:11px;display:flex;align-items:center;gap:6px;"><input type="checkbox" id="settingsHeadless"> Headless mode (hide browser window)</label>
  1302. <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>
  1303. <div style="display:flex;gap:12px;">
  1304. <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>
  1305. <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>
  1306. </div>
  1307. </div>
  1308. </div>
  1309. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1310. <label>Server</label>
  1311. <div style="display:flex;gap:8px;align-items:center;">
  1312. <span id="settingsVersion" style="font-size:11px;color:var(--text2);"></span>
  1313. <button class="hdr-btn" onclick="location.reload();" style="font-size:10px;">Reload Page</button>
  1314. </div>
  1315. </div>
  1316. <div class="modal-actions">
  1317. <button class="hdr-btn" onclick="closeSettings()">Cancel</button>
  1318. <button class="hdr-btn hdr-btn-primary" onclick="saveSettings()">Save</button>
  1319. </div>
  1320. </div>
  1321. </div>
  1322. <!-- Cloud Login Modal -->
  1323. <div class="modal-overlay" id="cloudLoginModal">
  1324. <div class="modal-box" style="max-width:400px;">
  1325. <h2>VL Cloud Login</h2>
  1326. <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>
  1327. <!-- Login Tabs -->
  1328. <div class="cloud-login-tabs">
  1329. <div class="cl-tab active" data-tab="enterprise" onclick="switchLoginTab('enterprise')">Enterprise</div>
  1330. <div class="cl-tab" data-tab="google" onclick="switchLoginTab('google')">Google</div>
  1331. <div class="cl-tab" data-tab="token" onclick="switchLoginTab('token')">Token</div>
  1332. </div>
  1333. <!-- Enterprise Login -->
  1334. <div class="cl-panel" id="clEnterprise">
  1335. <label>Email</label>
  1336. <input type="text" id="cloudUsername" placeholder="your@email.com" autocomplete="username">
  1337. <label>Password</label>
  1338. <input type="password" id="cloudPassword" placeholder="password" autocomplete="current-password">
  1339. <label>Company Name</label>
  1340. <input type="text" id="cloudCompany" placeholder="e.g. ivx">
  1341. <div class="modal-actions">
  1342. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1343. <button class="hdr-btn hdr-btn-primary" id="cloudLoginBtn" onclick="doEnterpriseLogin()">Login</button>
  1344. </div>
  1345. </div>
  1346. <!-- Google Login -->
  1347. <div class="cl-panel" id="clGoogle" style="display:none;">
  1348. <div style="text-align:center;padding:12px 0;">
  1349. <div id="googleSignInBtn" style="display:inline-block;"></div>
  1350. <div id="googleSignInFallback" style="display:none;padding-top:12px;">
  1351. <div style="font-size:11px;color:var(--text2);margin-bottom:8px;">Google Sign-In unavailable in this context.</div>
  1352. <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Platform Login in Browser</button>
  1353. <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
  1354. After logging in with Google on the platform,<br>
  1355. copy your <code>ih5bearer</code> cookie and paste it in the Token tab.
  1356. </div>
  1357. </div>
  1358. </div>
  1359. <div id="googleLoginStatus" style="text-align:center;font-size:11px;color:var(--text2);display:none;"></div>
  1360. <div class="modal-actions">
  1361. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1362. </div>
  1363. </div>
  1364. <!-- Token (Advanced) -->
  1365. <div class="cl-panel" id="clToken" style="display:none;">
  1366. <div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
  1367. Paste your <code>ih5bearer</code> token from the VL platform cookie.<br>
  1368. <span style="font-size:10px;">DevTools &rarr; Application &rarr; Cookies &rarr; ih5bearer</span>
  1369. </div>
  1370. <label>ih5bearer Token</label>
  1371. <input type="text" id="cloudDirectCookie" placeholder="eyJhbGciOiJI..." style="font-size:10px;">
  1372. <div class="modal-actions">
  1373. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1374. <button class="hdr-btn hdr-btn-primary" onclick="doTokenLogin()">Connect</button>
  1375. </div>
  1376. </div>
  1377. </div>
  1378. </div>
  1379. <!-- AutoTest Result Dialog -->
  1380. <div class="modal-overlay" id="autotestResultModal">
  1381. <div class="modal-box" style="max-width:500px;">
  1382. <h2 style="color:var(--orange);">AutoTest Results</h2>
  1383. <div id="autotestResultSummary" style="font-size:12px;margin-bottom:12px;"></div>
  1384. <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>
  1385. <label style="font-size:11px;color:var(--text2);">Choose an action:</label>
  1386. <div class="modal-actions" style="flex-direction:column;gap:6px;align-items:stretch;">
  1387. <button class="hdr-btn hdr-btn-primary" onclick="autotestAction('fix')" style="text-align:left;padding:8px 12px;">
  1388. AI Debug + Rerun — Send failures to AI for analysis and auto-fix
  1389. </button>
  1390. <button class="hdr-btn" onclick="autotestAction('report')" style="text-align:left;padding:8px 12px;">
  1391. Generate Report — View detailed test report
  1392. </button>
  1393. <button class="hdr-btn" onclick="autotestAction('skip')" style="text-align:left;padding:8px 12px;">
  1394. Skip — Close and handle manually
  1395. </button>
  1396. </div>
  1397. </div>
  1398. </div>
  1399. <script>
  1400. let currentFile = null;
  1401. let openFiles = new Map(); // key → { type:'file'|'workflow'|'metadata', content?, title?, data? }
  1402. let activeToolGroup = null;
  1403. let ctxMenuTarget = null; // path of right-clicked file
  1404. let currentMode = 'code'; // 'code' | 'meta' | 'flow' | 'docs' | 'preview'
  1405. let _workflowActive = false; // true while a workflow is executing (prevents mode-stealing)
  1406. let currentWorkDir = ''; // workspace folder path
  1407. let currentPort = location.port ? parseInt(location.port) : 80; // this instance's port
  1408. let _settingsSnapshot = null;
  1409. let showInternalFiles = false;
  1410. let workflowBindings = { generate: '3-file-codegen', adjust: 'incremental-update', autotest: 'autotest-pipeline' }; // workflow ID bindings
  1411. let flowRunning = false; // true while a workflow is executing
  1412. const $ = id => document.getElementById(id);
  1413. // ===================== CODEMIRROR SETUP =====================
  1414. // Define VL syntax mode for CodeMirror
  1415. CodeMirror.defineMode('vl', function() {
  1416. const keywords = new Set([
  1417. 'APP','SECTION','COMPONENT','SERVICE','PUBLIC_SERVICE','HANDLER','METHOD','METHOD_PUB',
  1418. 'TABLE','VT','VIRTUAL_TABLE','INDEX','RETURN','IF','ELSE','ELSEIF','FOR','IN','SET',
  1419. 'CALL','GOTO','WHILE','BREAK','CONTINUE','ENUM','NAV','ROUTE','DEVICE_TARGET',
  1420. 'SCREEN_RESOLUTION','STYLE','THEME','EVENT','DATA','FIELD','TRIGGER','BLOCK','TIMER',
  1421. 'TEMPLATE','QUERY','PARAM','COMPUTED','EMIT','USE','DEFAULT','FROM','PUSH','REMOVE',
  1422. 'MATCH','CONDITIONS','ALERT','CONFIRM','NAVIGATE','DISMISS','OPEN','CLOSE',
  1423. 'sourceArray','loopVar','conditions','returns','params','value','options','label',
  1424. 'placeholder','sourceTable','notNull','type','min-height','min-width',
  1425. ]);
  1426. const types = new Set([
  1427. 'STRING','INT','FLOAT','BOOL','BOOLEAN','NUMBER','TIMESTAMP','OBJECT','ARRAY','NULL',
  1428. 'UNIQUE','NORMAL','JSON','LIST','MAP','DATE','TEXT','DECIMAL',
  1429. ]);
  1430. const boolLit = new Set(['true','false','null','undefined']);
  1431. // Style attributes that appear as key:value (e.g. padding:"12px", gap:"8px")
  1432. const styleProps = new Set([
  1433. 'padding','margin','gap','width','height','display','flex','color','background-color',
  1434. 'background','border','border-radius','border-width','border-style','border-color',
  1435. 'font-size','font-weight','font-family','text-transform','text-align','text-decoration',
  1436. 'justify-content','align-items','flex-direction','flex-wrap','overflow','opacity',
  1437. 'position','top','left','right','bottom','z-index','cursor','transition','box-shadow',
  1438. 'margin-top','margin-bottom','margin-left','margin-right','padding-top','padding-bottom',
  1439. 'padding-left','padding-right','border-bottom','border-top','border-left','border-right',
  1440. 'max-width','max-height','min-width','min-height','line-height','letter-spacing',
  1441. 'object-fit','white-space','word-break','text-overflow',
  1442. ]);
  1443. return {
  1444. startState: function() { return { inTag: false }; },
  1445. token: function(stream, state) {
  1446. if (stream.eatSpace()) return null;
  1447. // === VL tree dashes (indent markers) ===
  1448. if (stream.sol() && stream.match(/^-+(?=\s|<|$)/)) return 'qualifier';
  1449. // === Section headers: # Name, ## Frontend Tree, etc. ===
  1450. if (stream.sol() && stream.match(/^#{1,3}\s+.*/)) return 'section-header';
  1451. // === Comments ===
  1452. if (stream.match(/^\/\/.*/)) return 'comment';
  1453. // === Tags: <Component-X>, <Text-Title>, </Row>, <ServiceDomain-Bet> ===
  1454. if (stream.match(/^<\/?[\w-]+/)) { state.inTag = true; return 'tag'; }
  1455. if (state.inTag) {
  1456. if (stream.eat('>')) { state.inTag = false; return 'tag'; }
  1457. if (stream.match(/^"[^"]*"/)) return 'string';
  1458. // key:value attributes inside tags (e.g. type:INT, sourceTable:Users)
  1459. if (stream.match(/^[\w-]+(?=:)/)) return 'attribute';
  1460. if (stream.eat(':')) return 'punctuation';
  1461. // remaining words inside tag are attribute values
  1462. if (stream.match(/^[\w.-]+/)) return 'variable';
  1463. stream.next();
  1464. return null;
  1465. }
  1466. // === $variables ===
  1467. if (stream.match(/^\$\w+/)) return 'variable-2';
  1468. // === @events / @handlers ===
  1469. if (stream.match(/^@\w+/)) return 'def';
  1470. // === Strings ===
  1471. if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return 'string';
  1472. if (stream.match(/^'(?:[^'\\]|\\.)*'/)) return 'string';
  1473. // === Numbers ===
  1474. if (stream.match(/^-?\d+(\.\d+)?(?!\w)/)) return 'number';
  1475. // === CSS variables: --colorBrandPrimary ===
  1476. if (stream.match(/^--[\w-]+/)) return 'atom';
  1477. // === Hex colors: #1a1a1a ===
  1478. if (stream.match(/^#[0-9a-fA-F]{3,8}\b/)) return 'number';
  1479. // === Word tokens (keywords, types, identifiers, properties) ===
  1480. if (stream.match(/^[\w-]+/)) {
  1481. const w = stream.current();
  1482. // Check keywords
  1483. if (keywords.has(w)) return 'keyword';
  1484. // Check types
  1485. if (types.has(w)) return 'type';
  1486. // Check booleans
  1487. if (boolLit.has(w)) return 'atom';
  1488. // Service.Method pattern: word followed by . and another word
  1489. if (stream.peek() === '.' && stream.match(/^\.[\w]+(?=\s*\()/)) return 'def';
  1490. // Property key before colon (e.g. padding: gap: font-size:)
  1491. if (stream.peek() === ':') return 'property';
  1492. // Style property names
  1493. if (styleProps.has(w)) return 'property';
  1494. // Known VL identifiers look-ahead: all-uppercase is likely a keyword/type we missed
  1495. if (/^[A-Z][A-Z_]+$/.test(w)) return 'keyword';
  1496. // PascalCase words are likely component/section/type references
  1497. if (/^[A-Z][a-z]/.test(w)) return 'variable-3';
  1498. // everything else — use default text color (light, visible)
  1499. return 'variable';
  1500. }
  1501. // === Operators & punctuation ===
  1502. if (stream.match(/^[(){}[\]]/)) return 'bracket';
  1503. if (stream.match(/^[=!<>]+/)) return 'operator';
  1504. if (stream.match(/^[,:;|&+*/%-]/)) return 'punctuation';
  1505. // Fallback: advance one char, return visible style
  1506. stream.next();
  1507. return 'variable';
  1508. }
  1509. };
  1510. });
  1511. // Register file extension → CodeMirror mode mapping
  1512. const CM_MODE_MAP = {
  1513. 'vx': 'vl', 'sc': 'vl', 'cp': 'vl', 'vs': 'vl', 'vdb': 'vl', 'vth': 'vl',
  1514. 'js': 'javascript', 'mjs': 'javascript', 'cjs': 'javascript',
  1515. 'json': { name: 'javascript', json: true },
  1516. 'css': 'css',
  1517. 'html': 'htmlmixed', 'htm': 'htmlmixed',
  1518. 'xml': 'xml', 'svg': 'xml',
  1519. 'md': 'text/plain', 'txt': 'text/plain',
  1520. };
  1521. function getCmMode(filePath) {
  1522. const ext = (filePath || '').split('.').pop().toLowerCase();
  1523. return CM_MODE_MAP[ext] || 'text/plain';
  1524. }
  1525. // Global CodeMirror editor instance
  1526. let cmEditor = null;
  1527. function initCodeMirror() {
  1528. if (cmEditor) return;
  1529. if (typeof CodeMirror === 'undefined') {
  1530. console.warn('CodeMirror not loaded, using textarea fallback');
  1531. cmEditor = null;
  1532. return;
  1533. }
  1534. cmEditor = CodeMirror($('cmEditorWrap'), {
  1535. value: '',
  1536. mode: 'vl',
  1537. theme: 'default',
  1538. lineNumbers: true,
  1539. matchBrackets: true,
  1540. autoCloseBrackets: true,
  1541. styleActiveLine: true,
  1542. indentUnit: 2,
  1543. tabSize: 2,
  1544. indentWithTabs: false,
  1545. lineWrapping: false,
  1546. foldGutter: true,
  1547. gutters: ['CodeMirror-linenumber', 'CodeMirror-foldgutter'],
  1548. extraKeys: {
  1549. 'Cmd-S': function() { saveCurrentFile(); },
  1550. 'Ctrl-S': function() { saveCurrentFile(); },
  1551. 'Tab': function(cm) {
  1552. if (cm.somethingSelected()) cm.indentSelection('add');
  1553. else cm.replaceSelection(' ', 'end');
  1554. },
  1555. }
  1556. });
  1557. // Track changes: update openFiles map
  1558. cmEditor.on('change', function() {
  1559. if (currentFile && openFiles.has(currentFile)) {
  1560. const info = openFiles.get(currentFile);
  1561. if (info.type === 'file') info.content = cmEditor.getValue();
  1562. }
  1563. });
  1564. }
  1565. // Chat state
  1566. let pendingImages = []; // [{data: base64, mediaType, preview}]
  1567. let pendingMentions = []; // [filename]
  1568. let allFileNames = []; // for @-mention autocomplete
  1569. let mentionIdx = -1; // selected autocomplete index
  1570. // Multi-conversation state — persisted in localStorage
  1571. let conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  1572. let activeConvId = 0;
  1573. let convIdCounter = 1;
  1574. function resetConversationState() {
  1575. conversations = [{ id: 0, name: 'Chat 1', messages: [], dom: '' }];
  1576. activeConvId = 0;
  1577. convIdCounter = 1;
  1578. if ($('chatMessages')) $('chatMessages').innerHTML = '';
  1579. renderConvTabs();
  1580. }
  1581. function setWorkspaceTriggerHighlight(active) {
  1582. const btn = document.querySelector('.ws-current');
  1583. if (btn) btn.classList.toggle('ws-btn-highlight', !!active);
  1584. }
  1585. function chatStorageKey(wsPath) {
  1586. const p = wsPath || currentWorkDir || '_global';
  1587. return 'vl-code-chat:' + p;
  1588. }
  1589. function saveChatState(wsPath) {
  1590. try {
  1591. const cur = conversations.find(c => c.id === activeConvId);
  1592. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  1593. const state = { conversations, activeConvId, convIdCounter };
  1594. localStorage.setItem(chatStorageKey(wsPath), JSON.stringify(state));
  1595. } catch {}
  1596. }
  1597. /** Fetch chat state from backend (single source of truth) */
  1598. async function fetchChatStateFromServer() {
  1599. try {
  1600. const res = await fetch('/api/chat/state');
  1601. if (!res.ok) return false;
  1602. const data = await res.json();
  1603. if (!data?.conversations?.length) return false;
  1604. conversations = data.conversations.map(c => ({
  1605. id: c.id, name: c.name, messages: c.messages || [], dom: c.dom || '',
  1606. messageCount: c.messageCount || 0,
  1607. }));
  1608. activeConvId = data.activeConvId ?? 0;
  1609. convIdCounter = data.convIdCounter ?? conversations.length;
  1610. // Auto-switch: if active conversation is empty but others have messages, pick first with messages
  1611. const cur = conversations.find(c => c.id === activeConvId);
  1612. if ((!cur || cur.messageCount === 0) && !cur?.dom) {
  1613. const withMessages = conversations.find(c => c.messageCount > 0 || c.dom);
  1614. if (withMessages) {
  1615. activeConvId = withMessages.id;
  1616. }
  1617. }
  1618. const target = conversations.find(c => c.id === activeConvId);
  1619. if ($('chatMessages')) {
  1620. if (target?.dom) {
  1621. // Check if DOM is stale (fewer messages than server has)
  1622. const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
  1623. if (domMsgCount < (target.messageCount || 0)) {
  1624. // DOM is stale — rebuild from server messages
  1625. $('chatMessages').innerHTML = '';
  1626. await _rebuildChatDom(target.id);
  1627. } else {
  1628. $('chatMessages').innerHTML = target.dom;
  1629. }
  1630. } else {
  1631. $('chatMessages').innerHTML = '';
  1632. // If dom is empty but server has messages, rebuild from server
  1633. if (target?.messageCount > 0) {
  1634. await _rebuildChatDom(target.id);
  1635. }
  1636. }
  1637. }
  1638. renderConvTabs();
  1639. // Write-through to localStorage as offline fallback
  1640. saveChatState();
  1641. return true;
  1642. } catch { return false; }
  1643. }
  1644. /**
  1645. * Rebuild chat DOM from server-side messages when dom snapshot is empty.
  1646. * Server endpoint already strips system-reminder content and flattens content blocks.
  1647. */
  1648. async function _rebuildChatDom(convId) {
  1649. try {
  1650. const res = await fetch(`/api/conversations/${convId}/messages`);
  1651. if (!res.ok) return;
  1652. const data = await res.json();
  1653. const msgs = data.messages || [];
  1654. if (!msgs.length) return;
  1655. const container = $('chatMessages');
  1656. if (!container) return;
  1657. container.innerHTML = '';
  1658. for (const m of msgs) {
  1659. if (!m.role || !m.content) continue;
  1660. const el = addMsg(m.role, m.content);
  1661. if (m.role === 'assistant' && el) finalizeAssistantMsg(el);
  1662. }
  1663. // Save rebuilt DOM into conversation object so it doesn't need rebuilding again
  1664. const conv = conversations.find(c => c.id === convId);
  1665. if (conv) conv.dom = container.innerHTML;
  1666. // Push rebuilt DOM to server for persistence
  1667. pushChatStateToServer();
  1668. } catch (e) {
  1669. console.warn('[RebuildDom] Failed for conv', convId, e);
  1670. }
  1671. }
  1672. /** Push chat state to backend (periodic save) */
  1673. async function pushChatStateToServer() {
  1674. const cur = conversations.find(c => c.id === activeConvId);
  1675. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  1676. const state = {
  1677. conversations: conversations.map(c => ({
  1678. id: c.id, name: c.name,
  1679. dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
  1680. })),
  1681. activeConvId,
  1682. convIdCounter,
  1683. };
  1684. try {
  1685. await fetch('/api/chat/state', {
  1686. method: 'POST',
  1687. headers: { 'Content-Type': 'application/json' },
  1688. body: JSON.stringify(state),
  1689. });
  1690. } catch {}
  1691. // Write-through to localStorage
  1692. saveChatState();
  1693. }
  1694. /** Persist debug log entries to filesystem */
  1695. async function persistDebugLog() {} // Debug panel removed
  1696. function loadChatState(wsPath) {
  1697. try {
  1698. const key = chatStorageKey(wsPath);
  1699. const raw = localStorage.getItem(key);
  1700. // Also try migrating old global key on first load
  1701. const fallback = !wsPath && !raw ? localStorage.getItem('vl-code-chat') : null;
  1702. const data = raw || fallback;
  1703. if (!data) return;
  1704. const state = JSON.parse(data);
  1705. if (state.conversations?.length) {
  1706. conversations = state.conversations;
  1707. activeConvId = state.activeConvId || 0;
  1708. convIdCounter = state.convIdCounter || conversations.length;
  1709. const cur = conversations.find(c => c.id === activeConvId);
  1710. if (cur?.dom && $('chatMessages')) {
  1711. $('chatMessages').innerHTML = cur.dom;
  1712. }
  1713. renderConvTabs();
  1714. }
  1715. // Clean up old global key after migration
  1716. if (fallback) localStorage.removeItem('vl-code-chat');
  1717. } catch {}
  1718. }
  1719. /** Sync client conversations with server sessions so chatId targeting works after refresh */
  1720. async function syncSessionsFromServer() {
  1721. try {
  1722. const data = await api('/api/sessions');
  1723. if (!data?.sessions?.length) return;
  1724. let changed = false;
  1725. for (const s of data.sessions) {
  1726. const id = Number(s.chatId);
  1727. if (isNaN(id)) continue;
  1728. if (!conversations.some(c => c.id === id)) {
  1729. conversations.push({ id, name: s.summary ? s.summary.substring(0, 30) : `Chat ${id + 1}`, messages: [] });
  1730. changed = true;
  1731. }
  1732. if (id >= convIdCounter) convIdCounter = id + 1;
  1733. }
  1734. if (changed) {
  1735. renderConvTabs();
  1736. saveChatState();
  1737. }
  1738. } catch {}
  1739. }
  1740. async function clearChatHistory() {
  1741. // Backend first — clears sessions + registry + broadcasts to other tabs
  1742. try {
  1743. await fetch('/api/conversations', { method: 'DELETE' });
  1744. } catch {}
  1745. // Then local
  1746. localStorage.removeItem(chatStorageKey());
  1747. conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  1748. activeConvId = 0;
  1749. convIdCounter = 1;
  1750. $('chatMessages').innerHTML = '';
  1751. renderConvTabs();
  1752. saveChatState();
  1753. }
  1754. // ===================== CHAT PANEL: RESIZE + COLLAPSE =====================
  1755. let chatWidth = parseInt(localStorage.getItem('vl-chat-width') || '400');
  1756. function getDockedChatReserveWidth() {
  1757. const panel = $('chatPanel');
  1758. if (!panel || panel.classList.contains('floating')) return 0;
  1759. return panel.classList.contains('collapsed') ? 36 : chatWidth;
  1760. }
  1761. function syncDockedLayout() {
  1762. const reserve = getDockedChatReserveWidth();
  1763. const panels = document.querySelector('.panels');
  1764. const detailPanel = $('detailPanel');
  1765. if (panels) panels.style.marginRight = reserve + 'px';
  1766. if (detailPanel) detailPanel.style.right = reserve > 0 ? `${reserve + 1}px` : '0';
  1767. }
  1768. function applyChatWidth(w) {
  1769. chatWidth = Math.max(300, Math.min(800, w));
  1770. const panel = $('chatPanel');
  1771. if (!panel.classList.contains('collapsed')) panel.style.width = chatWidth + 'px';
  1772. syncDockedLayout();
  1773. localStorage.setItem('vl-chat-width', String(chatWidth));
  1774. }
  1775. (function initChatResize() {
  1776. document.addEventListener('DOMContentLoaded', () => {
  1777. applyChatWidth(chatWidth);
  1778. const handle = $('chatResizeHandle');
  1779. if (!handle) return;
  1780. handle.addEventListener('mousedown', function(e) {
  1781. e.preventDefault();
  1782. this.classList.add('dragging');
  1783. const startX = e.clientX, startW = chatWidth;
  1784. function onMove(ev) { applyChatWidth(startW + (startX - ev.clientX)); }
  1785. function onUp() {
  1786. document.removeEventListener('mousemove', onMove);
  1787. document.removeEventListener('mouseup', onUp);
  1788. handle.classList.remove('dragging');
  1789. if (typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
  1790. }
  1791. document.addEventListener('mousemove', onMove);
  1792. document.addEventListener('mouseup', onUp);
  1793. });
  1794. // Drag-to-move chat panel by header
  1795. const header = document.querySelector('.chat-header');
  1796. const panel = $('chatPanel');
  1797. if (header && panel) {
  1798. header.addEventListener('mousedown', function(e) {
  1799. // Don't drag if clicking buttons inside header
  1800. if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
  1801. if (panel.classList.contains('collapsed')) return;
  1802. e.preventDefault();
  1803. const rect = panel.getBoundingClientRect();
  1804. const offsetX = e.clientX - rect.left;
  1805. const offsetY = e.clientY - rect.top;
  1806. const wasDocked = !panel.classList.contains('floating');
  1807. function onMove(ev) {
  1808. if (wasDocked && Math.abs(ev.clientX - e.clientX) < 8 && Math.abs(ev.clientY - e.clientY) < 8) return;
  1809. if (!panel.classList.contains('floating')) {
  1810. panel.classList.add('floating');
  1811. panel.style.height = Math.min(rect.height, window.innerHeight - 40) + 'px';
  1812. panel.style.right = 'auto';
  1813. panel.style.top = 'auto';
  1814. panel.style.bottom = 'auto';
  1815. syncDockedLayout();
  1816. }
  1817. let nx = ev.clientX - offsetX;
  1818. let ny = ev.clientY - offsetY;
  1819. nx = Math.max(0, Math.min(window.innerWidth - 100, nx));
  1820. ny = Math.max(0, Math.min(window.innerHeight - 40, ny));
  1821. panel.style.left = nx + 'px';
  1822. panel.style.top = ny + 'px';
  1823. }
  1824. function onUp(ev) {
  1825. document.removeEventListener('mousemove', onMove);
  1826. document.removeEventListener('mouseup', onUp);
  1827. // Snap back to docked if dragged to right edge
  1828. if (panel.classList.contains('floating')) {
  1829. const pr = panel.getBoundingClientRect();
  1830. if (pr.right >= window.innerWidth - 20) {
  1831. snapChatDocked();
  1832. }
  1833. }
  1834. }
  1835. document.addEventListener('mousemove', onMove);
  1836. document.addEventListener('mouseup', onUp);
  1837. });
  1838. }
  1839. });
  1840. })();
  1841. /** Snap chat panel back to docked right-side position */
  1842. function snapChatDocked() {
  1843. const panel = $('chatPanel');
  1844. panel.classList.remove('floating');
  1845. panel.style.left = '';
  1846. panel.style.top = '32px';
  1847. panel.style.right = '0';
  1848. panel.style.height = 'calc(100vh - 32px)';
  1849. panel.style.bottom = '';
  1850. applyChatWidth(chatWidth);
  1851. }
  1852. function toggleChatCollapse() {
  1853. const panel = $('chatPanel');
  1854. const isCollapsed = panel.classList.toggle('collapsed');
  1855. const w = isCollapsed ? 36 : chatWidth;
  1856. panel.style.width = w + 'px';
  1857. syncDockedLayout();
  1858. // Update collapse button arrow direction
  1859. const btn = panel.querySelector('.chat-collapse-btn');
  1860. if (btn) btn.innerHTML = isCollapsed ? '&#9654;' : '&#9664;';
  1861. if (!isCollapsed && typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
  1862. // Detail Panel follows main chat: hide when collapsed, restore when expanded
  1863. const detailPanel = $('detailPanel');
  1864. if (isCollapsed) {
  1865. // Save detail panel state before hiding
  1866. detailPanel._wasOpenBeforeCollapse = detailPanel.classList.contains('open');
  1867. detailPanel.classList.remove('open');
  1868. } else {
  1869. // Restore detail panel if it was open before collapse
  1870. if (detailPanel._wasOpenBeforeCollapse && !_detailManualClosed) {
  1871. detailPanel.classList.add('open');
  1872. }
  1873. }
  1874. }
  1875. function sendSkillCmd(name) {
  1876. $('chatInput').value = '/' + name;
  1877. sendMessage();
  1878. }
  1879. // ===================== ACTION SHORTCUTS =====================
  1880. function executeAction(actionName) {
  1881. const skillActions = {
  1882. 'validate': 'validate-all',
  1883. 'blueprint': 'blueprint',
  1884. 'deploy': 'deploy',
  1885. 'debug': 'debug',
  1886. };
  1887. const skillName = skillActions[actionName];
  1888. if (skillName) {
  1889. sendSkillCmd(skillName);
  1890. return;
  1891. }
  1892. setStatus('Unknown action: ' + actionName, 'red');
  1893. }
  1894. // ===================== WORKFLOW PROGRESS IN CHAT =====================
  1895. let _activeWfProgress = null;
  1896. /** Create a workflow progress widget in the chat panel */
  1897. function addWorkflowProgress(workflowName, steps) {
  1898. const container = $('chatMessages');
  1899. const div = document.createElement('div');
  1900. div.className = 'wf-progress';
  1901. div.id = 'wfProgress_' + Date.now();
  1902. const stepsHtml = steps.map(s => {
  1903. const typeLabel = s.type || s.id.split('_')[0];
  1904. return '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
  1905. '<span class="wf-step-dot pending"></span>' +
  1906. '<span>' + escapeHtml(s.title || s.id) + '</span>' +
  1907. '<span class="wf-step-type">' + escapeHtml(typeLabel) + '</span>' +
  1908. '</div>';
  1909. }).join('');
  1910. div.innerHTML = '<div class="wf-progress-header">' +
  1911. '<span class="wf-icon">&#9881;</span>' +
  1912. '<span>' + escapeHtml(workflowName) + '</span>' +
  1913. '</div>' + stepsHtml;
  1914. container.appendChild(div);
  1915. _activeWfProgress = div;
  1916. scrollChat();
  1917. return div;
  1918. }
  1919. /** Update a node's status dot in the active workflow progress widget */
  1920. function updateWfProgressNode(nodeId, status) {
  1921. if (!_activeWfProgress) return;
  1922. const step = _activeWfProgress.querySelector('[data-node-id="' + nodeId + '"]');
  1923. if (!step) return;
  1924. const dot = step.querySelector('.wf-step-dot');
  1925. if (dot) dot.className = 'wf-step-dot ' + status;
  1926. step.classList.toggle('active', status === 'running');
  1927. step.classList.toggle('completed', status === 'done');
  1928. }
  1929. /** Show a workflow approval prompt in chat (for LLM-generated workflows) */
  1930. function addWorkflowApproval(wfData) {
  1931. const container = $('chatMessages');
  1932. const div = document.createElement('div');
  1933. div.className = 'wf-progress';
  1934. const stepsHtml = (wfData.steps || []).map(s =>
  1935. '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
  1936. '<span class="wf-step-dot pending"></span>' +
  1937. '<span>' + escapeHtml(s.title || s.id) + '</span>' +
  1938. '<span class="wf-step-type">' + escapeHtml(s.type || '') + '</span>' +
  1939. '</div>'
  1940. ).join('');
  1941. div.innerHTML = '<div class="wf-progress-header">' +
  1942. '<span class="wf-icon">&#128736;</span>' +
  1943. '<span>Workflow: ' + escapeHtml(wfData.workflow?.name || wfData.name || 'Untitled') + '</span>' +
  1944. '</div>' +
  1945. stepsHtml +
  1946. '<div class="wf-progress-actions" id="wfApprovalActions_' + (wfData.name || '') + '">' +
  1947. '<button class="wf-approve-btn" onclick="approveAndRunWorkflow(\'' + escapeHtml(wfData.name || '') + '\', this)">&#10003; Approve & Run</button>' +
  1948. '<button class="wf-cancel-btn" onclick="this.closest(\'.wf-progress-actions\').innerHTML=\'<span style=color:var(--red);font-size:10px>Cancelled.</span>\'">&#10007; Cancel</button>' +
  1949. '<button onclick="viewWorkflow(\'' + escapeHtml(wfData.name || '') + '\');switchMode(\'flow\')">View DAG</button>' +
  1950. '</div>';
  1951. container.appendChild(div);
  1952. scrollChat();
  1953. return div;
  1954. }
  1955. // ── Workflow LLM Chat Streaming ──
  1956. // Streams LLM thinking/response/tool messages into the main chat window
  1957. // during local workflow execution, so the user sees everything in real-time.
  1958. let _wfLlmChatEl = null; // Current chat message element
  1959. let _wfLlmThinkingEl = null; // Thinking collapsible block inside chat
  1960. let _wfLlmResponseEl = null; // Response text element inside chat
  1961. let _wfLlmRawText = ''; // Raw accumulated response for markdown render
  1962. let _wfLlmFlushTimer = null; // Batch DOM updates
  1963. function _wfLlmChatEnsure() {
  1964. if (_wfLlmChatEl) return;
  1965. const container = $('chatMessages');
  1966. const div = document.createElement('div');
  1967. div.className = 'msg assistant';
  1968. div.style.position = 'relative';
  1969. const now = formatMsgTime(new Date());
  1970. div.innerHTML = `<div class="label">assistant <span class="msg-time">${now}</span></div>` +
  1971. `<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>` +
  1972. `<span class="content-text"></span>`;
  1973. container.appendChild(div);
  1974. _wfLlmChatEl = div;
  1975. _wfLlmThinkingEl = div.querySelector('.wf-llm-thinking');
  1976. _wfLlmResponseEl = div.querySelector('.content-text');
  1977. _wfLlmRawText = '';
  1978. scrollChat();
  1979. }
  1980. function _wfLlmChatAppend(type, text) {
  1981. _wfLlmChatEnsure();
  1982. if (type === 'thinking') {
  1983. _wfLlmThinkingEl.style.display = '';
  1984. _wfLlmThinkingEl.textContent += text;
  1985. } else {
  1986. _wfLlmRawText += text;
  1987. // Batch DOM updates for smooth rendering
  1988. if (!_wfLlmFlushTimer) {
  1989. _wfLlmFlushTimer = setTimeout(() => {
  1990. _wfLlmResponseEl.textContent = _wfLlmRawText;
  1991. _wfLlmFlushTimer = null;
  1992. scrollChat();
  1993. }, 100);
  1994. }
  1995. }
  1996. }
  1997. function _wfLlmChatToolUse(name, input) {
  1998. _wfLlmChatEnsure();
  1999. const toolDiv = document.createElement('div');
  2000. toolDiv.style.cssText = 'margin:4px 0;padding:4px 8px;background:var(--bg2);border-radius:4px;font-size:10px;border-left:2px solid var(--accent);';
  2001. const inputStr = JSON.stringify(input);
  2002. const short = inputStr.length > 120 ? inputStr.slice(0, 120) + '...' : inputStr;
  2003. toolDiv.innerHTML = `<span style="color:var(--accent);font-weight:600;">🔧 ${escapeHtml(name)}</span> <span style="color:var(--text2);">${escapeHtml(short)}</span>`;
  2004. toolDiv.style.cursor = 'pointer';
  2005. toolDiv.title = 'Click to expand';
  2006. toolDiv.onclick = () => { toolDiv.querySelector('.wf-tool-full')?.classList.toggle('collapsed'); };
  2007. if (inputStr.length > 120) {
  2008. 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>`;
  2009. }
  2010. _wfLlmChatEl.appendChild(toolDiv);
  2011. scrollChat();
  2012. }
  2013. function _wfLlmChatToolResult(result, isError) {
  2014. _wfLlmChatEnsure();
  2015. const resDiv = document.createElement('div');
  2016. 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;`;
  2017. resDiv.onclick = () => { resDiv.style.maxHeight = resDiv.style.maxHeight === '100px' ? 'none' : '100px'; };
  2018. const short = result.length > 200 ? result.slice(0, 200) + '...' : result;
  2019. 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>`;
  2020. if (result.length > 200) {
  2021. resDiv.innerHTML += `<div style="margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2);">${escapeHtml(result)}</div>`;
  2022. }
  2023. _wfLlmChatEl.appendChild(resDiv);
  2024. scrollChat();
  2025. }
  2026. function _wfLlmChatFinalize(summary) {
  2027. if (!_wfLlmChatEl) return;
  2028. // Flush pending text
  2029. if (_wfLlmFlushTimer) { clearTimeout(_wfLlmFlushTimer); _wfLlmFlushTimer = null; }
  2030. // Render markdown
  2031. if (_wfLlmRawText && _wfLlmResponseEl) {
  2032. _wfLlmResponseEl.innerHTML = renderMarkdown(_wfLlmRawText);
  2033. // Add Apply buttons to code blocks
  2034. _wfLlmResponseEl.querySelectorAll('pre').forEach(pre => {
  2035. const btn = document.createElement('button');
  2036. btn.className = 'code-apply';
  2037. btn.textContent = 'Apply';
  2038. btn.onclick = () => applyCodeBlock(pre);
  2039. pre.style.position = 'relative';
  2040. pre.appendChild(btn);
  2041. });
  2042. }
  2043. // Add usage summary footer
  2044. if (summary) {
  2045. const footer = document.createElement('div');
  2046. footer.style.cssText = 'font-size:9px;color:var(--text2);margin-top:4px;padding-top:4px;border-top:1px solid var(--border);';
  2047. footer.textContent = summary;
  2048. _wfLlmChatEl.appendChild(footer);
  2049. }
  2050. scrollChat();
  2051. // Reset for next LLM call within same workflow
  2052. _wfLlmChatEl = null;
  2053. _wfLlmThinkingEl = null;
  2054. _wfLlmResponseEl = null;
  2055. _wfLlmRawText = '';
  2056. }
  2057. function _wfLlmChatError(errMsg, retryable) {
  2058. _wfLlmChatEnsure();
  2059. const errDiv = document.createElement('div');
  2060. errDiv.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;';
  2061. errDiv.textContent = `✗ LLM Error${retryable ? ' (retryable)' : ''}: ${errMsg}`;
  2062. _wfLlmChatEl.appendChild(errDiv);
  2063. scrollChat();
  2064. _wfLlmChatEl = null;
  2065. _wfLlmThinkingEl = null;
  2066. _wfLlmResponseEl = null;
  2067. _wfLlmRawText = '';
  2068. }
  2069. /** Approve and execute a workflow from chat */
  2070. async function approveAndRunWorkflow(name, btn) {
  2071. const actionsDiv = btn.closest('.wf-progress-actions');
  2072. actionsDiv.innerHTML = '<span style="color:var(--green);font-size:10px;">Approved. Executing...</span>';
  2073. const approvalWidget = btn.closest('.wf-progress');
  2074. _activeWfProgress = approvalWidget;
  2075. try {
  2076. const res = await fetch('/api/workflow/execute', {
  2077. method: 'POST',
  2078. headers: { 'Content-Type': 'application/json' },
  2079. body: JSON.stringify({ workflowName: name, params: {} }),
  2080. });
  2081. const reader = res.body.getReader();
  2082. const decoder = new TextDecoder();
  2083. let buffer = '';
  2084. while (true) {
  2085. const { done, value } = await reader.read();
  2086. if (done) break;
  2087. buffer += decoder.decode(value, { stream: true });
  2088. const blocks = buffer.split('\n\n');
  2089. buffer = blocks.pop();
  2090. for (const block of blocks) {
  2091. let eType = 'message', eData = null;
  2092. for (const line of block.split('\n')) {
  2093. if (line.startsWith('event: ')) eType = line.slice(7).trim();
  2094. else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
  2095. }
  2096. if (!eData) continue;
  2097. switch (eType) {
  2098. case 'workflow_start': {
  2099. const wfModel = eData.model ? ` [${eData.model}]` : '';
  2100. addDetailEntry('workflow', `► Workflow started: ${eData.name || ''}${wfModel} (${eData.stepCount || '?'} steps)`, null, 'info');
  2101. addMsg('assistant', `**▶ Workflow: ${eData.name || 'running'}**${wfModel ? ' — Model: ' + eData.model : ''}`);
  2102. break;
  2103. }
  2104. case 'node_start': {
  2105. updateWfProgressNode(eData.nodeId, 'running');
  2106. const nsLabel = eData.title || eData.nodeId;
  2107. const nsType = eData.type ? `[${eData.type}] ` : '';
  2108. const nsInput = eData.input ? JSON.stringify(eData.input, null, 2) : null;
  2109. addDetailEntry('node', `▶ ${nsType}${nsLabel}`, nsInput, 'info');
  2110. addMsg('assistant', `**Step: ${nsType}${nsLabel}**`);
  2111. break;
  2112. }
  2113. case 'node_done': {
  2114. updateWfProgressNode(eData.nodeId, 'done');
  2115. const ndLabel = eData.title || eData.nodeId;
  2116. const ndDur = eData.duration_ms ? ` (${eData.duration_ms >= 1000 ? (eData.duration_ms / 1000).toFixed(1) + 's' : eData.duration_ms + 'ms'})` : '';
  2117. const ndOutput = eData.output ? JSON.stringify(eData.output, null, 2) : null;
  2118. addDetailEntry('node', `✓ ${ndLabel}${ndDur}`, ndOutput, 'success');
  2119. break;
  2120. }
  2121. case 'node_error': {
  2122. updateWfProgressNode(eData.nodeId, 'error');
  2123. const neLabel = eData.title || eData.nodeId;
  2124. const neType = eData.type ? `[${eData.type}] ` : '';
  2125. const neDur = eData.duration_ms ? ` (${(eData.duration_ms / 1000).toFixed(1)}s)` : '';
  2126. addDetailEntry('node', `✗ ${neType}${neLabel}${neDur} — ${eData.error || ''}`, eData.detail || null, 'error');
  2127. addMsg('assistant', `**✗ Error: ${neLabel}** — ${eData.error || ''}`);
  2128. break;
  2129. }
  2130. case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
  2131. // ── Extended LLM events → Detail Log + Main Chat ──
  2132. case 'llm_thinking':
  2133. appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
  2134. _wfLlmChatAppend('thinking', eData.delta || '');
  2135. break;
  2136. case 'token':
  2137. appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || eData.delta || '');
  2138. _wfLlmChatAppend('response', eData.token || eData.delta || '');
  2139. break;
  2140. case 'llm_tool_use': {
  2141. const ltuInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
  2142. addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, ltuInput, 'info', { depth: 1 });
  2143. _wfLlmChatToolUse(eData.name || 'unknown', eData.input || {});
  2144. break;
  2145. }
  2146. case 'llm_tool_result': {
  2147. const isErr = eData.is_error || false;
  2148. const rc = eData.content || '';
  2149. const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
  2150. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${eData.name || 'Result'}${eData.tool_use_id ? ' [' + eData.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
  2151. _wfLlmChatToolResult(rs, isErr);
  2152. break;
  2153. }
  2154. case 'tool_start': {
  2155. const toolInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
  2156. addDetailEntry('tool-call', `🛠 ${eData.name || eData.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
  2157. break;
  2158. }
  2159. case 'tool_done': {
  2160. const toolOutput = eData.output ? (typeof eData.output === 'string' ? eData.output : JSON.stringify(eData.output, null, 2)) : null;
  2161. addDetailEntry('tool-result', `✓ ${eData.name || eData.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
  2162. break;
  2163. }
  2164. case 'tool_error': {
  2165. addDetailEntry('tool-result', `✗ ${eData.name || eData.stepId || 'tool'}${eData.allowError ? ' (continued)' : ''}`, eData.error || null, eData.allowError ? 'warn' : 'error', { depth: 1 });
  2166. break;
  2167. }
  2168. case 'tool_message': {
  2169. const detail = eData.data ? (typeof eData.data === 'string' ? eData.data : JSON.stringify(eData.data, null, 2)) : null;
  2170. addDetailEntry('tool-call', `• ${eData.name || eData.stepId || 'tool'}: ${eData.message || ''}`, detail, eData.level === 'error' ? 'error' : eData.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  2171. break;
  2172. }
  2173. case 'llm_done': {
  2174. flushStreamBoxes();
  2175. const mdl = eData.model || '';
  2176. const usg = eData.usage || {};
  2177. const inTok = usg.input_tokens || 0;
  2178. const outTok = usg.output_tokens || 0;
  2179. const cacheTok = usg.cache_read_input_tokens || 0;
  2180. const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
  2181. const tokParts = [];
  2182. if (inTok) tokParts.push(`in:${inTok}`);
  2183. if (cacheTok) tokParts.push(`cache:${cacheTok}`);
  2184. if (outTok) tokParts.push(`out:${outTok}`);
  2185. const parts = [mdl, tokParts.join(' '), lat].filter(Boolean).join(' | ');
  2186. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  2187. _wfLlmChatFinalize(parts);
  2188. break;
  2189. }
  2190. case 'llm_error': {
  2191. const errParts = [eData.error || 'Unknown'];
  2192. if (eData.type) errParts.push(`type:${eData.type}`);
  2193. if (eData.code) errParts.push(`code:${eData.code}`);
  2194. addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${errParts.join(' | ')}`, eData, 'error');
  2195. _wfLlmChatError(eData.error || 'Unknown LLM error', eData.retryable);
  2196. break;
  2197. }
  2198. case 'var_changed': {
  2199. const vn = eData.name || '?';
  2200. const vo = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 120) : '—';
  2201. const vn2 = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 120) : '—';
  2202. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, eData, 'info', { depth: 1 });
  2203. break;
  2204. }
  2205. case 'file_start':
  2206. addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
  2207. break;
  2208. case 'pause':
  2209. updateWfProgressNode(eData.nodeId, 'paused');
  2210. addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
  2211. addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
  2212. break;
  2213. case 'resumed':
  2214. updateWfProgressNode(eData.nodeId, 'running');
  2215. addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
  2216. break;
  2217. case 'file_written':
  2218. { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
  2219. break;
  2220. case 'done':
  2221. flushStreamBoxes();
  2222. addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
  2223. addDetailEntry('workflow', 'Workflow completed', null, 'success');
  2224. await loadFileTree();
  2225. break;
  2226. case 'error':
  2227. addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
  2228. addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
  2229. break;
  2230. }
  2231. }
  2232. }
  2233. } catch (e) {
  2234. addMsg('assistant', '**Workflow execution error:** ' + e.message);
  2235. }
  2236. _activeWfProgress = null;
  2237. }
  2238. /** Show Pause/Resume UI in chat */
  2239. function addPauseResumeUI(nodeId, title, runID) {
  2240. const container = $('chatMessages');
  2241. const div = document.createElement('div');
  2242. div.className = 'wf-progress';
  2243. div.innerHTML = '<div class="wf-progress-header">' +
  2244. '<span class="wf-icon" style="color:var(--purple);">&#9208;</span>' +
  2245. '<span>Paused: ' + escapeHtml(title || nodeId) + '</span>' +
  2246. '</div>' +
  2247. '<div style="padding:4px 0;font-size:10px;color:var(--text2);">Review the current state and approve to continue.</div>' +
  2248. '<div class="wf-progress-actions">' +
  2249. '<button class="wf-approve-btn">&#10003; Continue</button>' +
  2250. '<button class="wf-cancel-btn">&#10007; Abort</button>' +
  2251. '</div>';
  2252. const approveBtn = div.querySelector('.wf-approve-btn');
  2253. const cancelBtn = div.querySelector('.wf-cancel-btn');
  2254. if (approveBtn) approveBtn.onclick = () => resumeWorkflow(nodeId, runID, approveBtn);
  2255. if (cancelBtn) cancelBtn.onclick = () => cancelWorkflow(nodeId, runID, cancelBtn);
  2256. container.appendChild(div);
  2257. scrollChat();
  2258. }
  2259. /** Resume a paused workflow */
  2260. async function resumeWorkflow(nodeId, runID, btn) {
  2261. btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--green);font-size:10px;">Resumed...</span>';
  2262. await fetch('/api/workflow/resume', {
  2263. method: 'POST',
  2264. headers: { 'Content-Type': 'application/json' },
  2265. body: JSON.stringify({ runID, nodeId, payload: { approved: true } }),
  2266. });
  2267. }
  2268. /** Cancel a paused workflow */
  2269. async function cancelWorkflow(nodeId, runID, btn) {
  2270. btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--red);font-size:10px;">Aborted.</span>';
  2271. await fetch('/api/workflow/cancel', {
  2272. method: 'POST',
  2273. headers: { 'Content-Type': 'application/json' },
  2274. body: JSON.stringify({ runID, nodeId }),
  2275. });
  2276. }
  2277. // ===================== LANDING PAGE =====================
  2278. function switchLandingTab(tab) {
  2279. document.querySelectorAll('.landing-tab').forEach(t => t.classList.toggle('active', t.dataset.ltab === tab));
  2280. document.querySelectorAll('.landing-tab-panel').forEach(p => p.classList.remove('active'));
  2281. const panelMap = { enterprise: 'ltEnterprise', google: 'ltGoogle', token: 'ltToken' };
  2282. if (panelMap[tab]) $(panelMap[tab]).classList.add('active');
  2283. }
  2284. async function doLandingEnterpriseLogin() {
  2285. const username = $('landingUsername').value.trim();
  2286. const password = $('landingPassword').value.trim();
  2287. const companyName = $('landingCompany').value.trim();
  2288. if (!username || !password) { $('landingLoginError').textContent = 'Email and password required'; $('landingLoginError').style.display = 'block'; return; }
  2289. $('landingLoginError').style.display = 'none';
  2290. try {
  2291. const res = await fetch('/api/cloud/login', {
  2292. method: 'POST', headers: {'Content-Type':'application/json'},
  2293. body: JSON.stringify({ username, password, companyName })
  2294. });
  2295. const data = await res.json();
  2296. if (data.error) { $('landingLoginError').textContent = data.error; $('landingLoginError').style.display = 'block'; return; }
  2297. _cloudConnected = true;
  2298. enterIDE();
  2299. } catch (e) { $('landingLoginError').textContent = e.message; $('landingLoginError').style.display = 'block'; }
  2300. }
  2301. async function doLandingTokenLogin() {
  2302. const cookie = $('landingDirectCookie').value.trim();
  2303. if (!cookie) return;
  2304. await fetch('/api/cookie/refresh', {
  2305. method: 'POST', headers: {'Content-Type':'application/json'},
  2306. body: JSON.stringify({ cookie })
  2307. });
  2308. _cloudConnected = true;
  2309. enterIDE();
  2310. }
  2311. function refreshLandingDocsFrame() {
  2312. const frame = $('landingDocsFrame');
  2313. if (!frame) return;
  2314. frame.src = `/doc-center.html?embed=landing&t=${Date.now()}`;
  2315. }
  2316. async function enterIDE() {
  2317. // Save API key if provided
  2318. const apiKey = $('landingApiKey')?.value?.trim();
  2319. if (apiKey) {
  2320. await fetch('/api/settings', {
  2321. method: 'POST', headers: {'Content-Type':'application/json'},
  2322. body: JSON.stringify({ apiKey })
  2323. });
  2324. }
  2325. // Mark as entered and hide landing
  2326. sessionStorage.setItem('vlcode_entered', '1');
  2327. $('landingOverlay').classList.remove('active');
  2328. await initIDE();
  2329. }
  2330. async function checkCliStatus() {
  2331. try {
  2332. const res = await fetch('/api/cli-status');
  2333. const data = await res.json();
  2334. const el = $('landingCliStatus');
  2335. if (data.available) {
  2336. el.innerHTML = '<span style="color:var(--green);">&#10003; Claude CLI detected — Team subscription active, no API Key needed</span>';
  2337. el.style.display = 'block';
  2338. } else {
  2339. el.innerHTML = '<span style="color:var(--yellow);">&#9888; Claude CLI not detected — API Key may be needed</span>';
  2340. el.style.display = 'block';
  2341. }
  2342. return data;
  2343. } catch { return { available: false }; }
  2344. }
  2345. function updateLlmBadge(provider) {
  2346. const badge = $('llmBadge');
  2347. if (!badge) return;
  2348. if (provider === 'cli') {
  2349. badge.textContent = 'CLI';
  2350. badge.className = 'llm-badge cli';
  2351. badge.title = 'Using Claude CLI (Team subscription)';
  2352. } else if (provider === 'api-key') {
  2353. badge.textContent = 'API Key';
  2354. badge.className = 'llm-badge apikey';
  2355. badge.title = 'Using Anthropic API Key';
  2356. } else {
  2357. badge.textContent = provider || 'CLI';
  2358. badge.className = 'llm-badge cli';
  2359. }
  2360. }
  2361. function getSelectedSettingsProvider() {
  2362. return document.querySelector('input[name="settingsProvider"]:checked')?.value || 'cli';
  2363. }
  2364. function renderProviderSettingsState(settings = {}) {
  2365. _settingsSnapshot = { ...(_settingsSnapshot || {}), ...settings };
  2366. const selected = _settingsSnapshot.llmProvider || 'cli';
  2367. const effective = _settingsSnapshot.effectiveProvider || selected;
  2368. const cliAvailable = !!_settingsSnapshot.cliAvailable;
  2369. const hasApiKey = !!_settingsSnapshot.hasApiKey;
  2370. $('settingsProviderCli').checked = selected === 'cli';
  2371. $('settingsProviderApiKey').checked = selected === 'api-key';
  2372. const summary = effective === 'cli'
  2373. ? '<span class="key-ok" style="color:var(--green);">Active provider: CLI</span>'
  2374. : '<span class="key-ok">Active provider: API Key</span>';
  2375. $('keyStatus').innerHTML = summary;
  2376. let hint = '';
  2377. if (selected === 'cli') {
  2378. hint = cliAvailable
  2379. ? 'CLI mode selected. This is the recommended lower-cost path when Claude CLI is installed.'
  2380. : (hasApiKey
  2381. ? 'CLI mode selected, but Claude CLI is not available here. Runtime will fall back to API Key until CLI is installed.'
  2382. : 'CLI mode selected, but Claude CLI is not available yet.');
  2383. } else {
  2384. hint = hasApiKey
  2385. ? 'API Key mode selected. Requests will use the configured Anthropic key directly.'
  2386. : (cliAvailable
  2387. ? 'API Key mode selected, but no key is configured. Runtime will fall back to CLI.'
  2388. : 'API Key mode selected, but no key is configured yet.');
  2389. }
  2390. $('settingsProviderHint').textContent = hint;
  2391. }
  2392. // ===================== INIT =====================
  2393. async function init() {
  2394. const entered = sessionStorage.getItem('vlcode_entered');
  2395. let cloudLoggedIn = false;
  2396. try {
  2397. const cs = await api('/api/cloud/status');
  2398. if (cs.loggedIn) {
  2399. _cloudConnected = true;
  2400. cloudLoggedIn = true;
  2401. }
  2402. } catch {}
  2403. if (!isDesktopApp() && !entered && !cloudLoggedIn) {
  2404. $('landingOverlay').classList.add('active');
  2405. checkCliStatus();
  2406. refreshLandingDocsFrame();
  2407. return;
  2408. }
  2409. sessionStorage.setItem('vlcode_entered', '1');
  2410. await initIDE();
  2411. }
  2412. async function initIDE() {
  2413. syncDesktopWorkspaceUI();
  2414. const settings = await api('/api/settings');
  2415. applyDocIdSettings(settings);
  2416. updateLlmBadge(settings.effectiveProvider || settings.llmProvider || 'cli');
  2417. setInternalFilesVisible(localStorage.getItem('vl-code-show-internal') === '1', { reload: false, persist: false });
  2418. const proj = await api('/api/project');
  2419. currentWorkDir = proj.workDir || '';
  2420. if (proj.port) currentPort = proj.port;
  2421. if (proj.version) $('appVersion').textContent = 'v' + proj.version;
  2422. $('chatModel').textContent = shortModel(proj.model);
  2423. $('modelLabel').textContent = shortModel(proj.model);
  2424. // Determine if a valid VL workspace is loaded
  2425. const hasVLProject = proj.isVL && proj.summary?.totalFiles > 0;
  2426. if (hasVLProject) {
  2427. // VL project loaded — show project info and file tree
  2428. const wsName = proj.summary?.projectName || path_basename(proj.workDir);
  2429. $('projectInfo').textContent = `${proj.summary.totalFiles} files`;
  2430. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2431. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2432. _tabWorkspaceName = wsName;
  2433. setTabStatus(_tabStatus);
  2434. await loadFileTree();
  2435. } else if (currentWorkDir) {
  2436. // Non-VL workspace selected — still show file tree and workspace name
  2437. const wsName = path_basename(currentWorkDir);
  2438. $('projectInfo').textContent = 'Workspace';
  2439. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2440. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2441. _tabWorkspaceName = wsName;
  2442. setTabStatus(_tabStatus);
  2443. await loadFileTree();
  2444. } else {
  2445. // No workspace at all — show "Open File" prompt
  2446. $('projectInfo').textContent = '';
  2447. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2448. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2449. _tabWorkspaceName = '';
  2450. $('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>';
  2451. }
  2452. await loadWorkspaces();
  2453. // Show workspace selector on startup if no workspace loaded at all
  2454. if (!currentWorkDir) {
  2455. resetConversationState();
  2456. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2457. $('chatInput').disabled = false;
  2458. $('chatSend').disabled = false;
  2459. }
  2460. await checkCloudLoginStatus();
  2461. if (currentWorkDir) {
  2462. await Promise.all([
  2463. loadPreviewUrlsFromProfile(),
  2464. loadCloudGid(),
  2465. ]);
  2466. }
  2467. updateContext();
  2468. connectSSE();
  2469. setupImagePaste();
  2470. if (currentWorkDir) {
  2471. // Restore chat state from backend (single source of truth)
  2472. const chatStateRestored = await fetchChatStateFromServer();
  2473. if (!chatStateRestored) {
  2474. loadChatState();
  2475. }
  2476. // Restore AI session context (messages, todos) from backend
  2477. try {
  2478. const chatId = activeConvId ?? 0;
  2479. const sessStatus = await api(`/api/session/${chatId}/status`);
  2480. if (sessStatus?.restored && sessStatus.messageCount > 0) {
  2481. console.log(`[Session] Restored ${sessStatus.messageCount} messages from ${sessStatus.source} (${sessStatus.turnCount} turns)`);
  2482. if (sessStatus.todos?.length) renderTodos(sessStatus.todos);
  2483. }
  2484. } catch {}
  2485. // Restore workspace UI state (files, mode — NOT chat)
  2486. if (hasVLProject) {
  2487. await restoreWorkspaceState();
  2488. }
  2489. } else {
  2490. resetConversationState();
  2491. switchMode('docs');
  2492. }
  2493. // Unified save: push chat state to backend every 10s
  2494. setInterval(pushChatStateToServer, 10000);
  2495. // Save workspace state (non-chat: files, mode) every 30s
  2496. setInterval(saveWorkspaceState, 30000);
  2497. // Keep window tab bar in sync with running instances
  2498. setInterval(renderWsTabs, 5000);
  2499. // Save state before page unload
  2500. window.addEventListener('beforeunload', () => {
  2501. // Push chat state to backend via sendBeacon
  2502. const cur = conversations.find(c => c.id === activeConvId);
  2503. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  2504. const chatState = {
  2505. conversations: conversations.map(c => ({
  2506. id: c.id, name: c.name,
  2507. dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
  2508. })),
  2509. activeConvId,
  2510. convIdCounter,
  2511. };
  2512. navigator.sendBeacon('/api/chat/state', new Blob([JSON.stringify(chatState)], { type: 'application/json' }));
  2513. // Also save to localStorage as offline fallback
  2514. saveChatState();
  2515. // Workspace state (non-chat fields)
  2516. const wsState = {
  2517. savedAt: Date.now(),
  2518. mode: currentMode || 'code',
  2519. activeFile: currentFile || null,
  2520. openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
  2521. debugPanelOpen: $('debugPanel')?.style.display !== 'none',
  2522. chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
  2523. chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
  2524. showInternalFiles,
  2525. wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
  2526. };
  2527. navigator.sendBeacon('/api/workspace/state', new Blob([JSON.stringify(wsState)], { type: 'application/json' }));
  2528. });
  2529. }
  2530. async function loadProjectInfo() {
  2531. const proj = await api('/api/project');
  2532. const vlCount = proj.summary?.totalFiles || 0;
  2533. const hasVL = proj.isVL && vlCount > 0;
  2534. $('chatModel').textContent = shortModel(proj.model);
  2535. if (proj.version) $('appVersion').textContent = 'v' + proj.version;
  2536. $('modelLabel').textContent = shortModel(proj.model);
  2537. currentWorkDir = proj.workDir || '';
  2538. if (hasVL) {
  2539. const wsName = proj.summary?.projectName || path_basename(proj.workDir);
  2540. $('projectInfo').textContent = `${vlCount} files`;
  2541. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2542. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2543. _tabWorkspaceName = wsName;
  2544. setTabStatus(_tabStatus);
  2545. } else if (currentWorkDir) {
  2546. const wsName = path_basename(currentWorkDir);
  2547. $('projectInfo').textContent = 'Workspace';
  2548. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2549. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2550. _tabWorkspaceName = wsName;
  2551. setTabStatus(_tabStatus);
  2552. } else {
  2553. $('projectInfo').textContent = '';
  2554. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2555. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2556. _tabWorkspaceName = '';
  2557. setTabStatus(_tabStatus);
  2558. $('chatInput').disabled = false;
  2559. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2560. $('chatSend').disabled = false;
  2561. }
  2562. // Disable compile button for non-VL workspaces
  2563. const compileBtn = $('compileBtn');
  2564. if (compileBtn) {
  2565. if (!proj.isVL) {
  2566. compileBtn.style.opacity = '0.4';
  2567. compileBtn.title = 'No VL files in current workspace';
  2568. } else {
  2569. compileBtn.style.opacity = '1';
  2570. compileBtn.title = 'Compile & Preview';
  2571. }
  2572. }
  2573. renderWsTabs();
  2574. }
  2575. function shortModel(m) {
  2576. if (m?.includes('opus')) return 'Opus 4.6';
  2577. if (m?.includes('sonnet')) return 'Sonnet 4.6';
  2578. if (m?.includes('haiku')) return 'Haiku 4.5';
  2579. return m || '';
  2580. }
  2581. // ===================== SETUP KEY =====================
  2582. // Landing page enter key handlers
  2583. $('landingApiKey')?.addEventListener('keydown', e => { if (e.key === 'Enter') enterIDE(); });
  2584. $('landingPassword')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingEnterpriseLogin(); });
  2585. $('landingDirectCookie')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingTokenLogin(); });
  2586. // ===================== WORKSPACE (MULTI-WINDOW) =====================
  2587. let _wsInstances = []; // running instances: { port, workDir, pid, startedAt }
  2588. function isDesktopApp() {
  2589. return !!window.vlcodeDesktop?.isElectron;
  2590. }
  2591. function syncDesktopWorkspaceUI() {
  2592. const openFolderBtn = $('wsOpenFolderBtn');
  2593. const pickLocationBtn = $('newProjectLocationPickBtn');
  2594. document.body.classList.toggle('desktop-app', isDesktopApp());
  2595. if (openFolderBtn) openFolderBtn.style.display = isDesktopApp() ? '' : 'none';
  2596. if (pickLocationBtn) pickLocationBtn.style.display = isDesktopApp() ? '' : 'none';
  2597. }
  2598. /** Render current workspace in the header */
  2599. async function renderWsTabs() {
  2600. _renderWsTabsDom();
  2601. }
  2602. /** Kept for SSE compat — instances list is now authoritative */
  2603. function renderWsTabsFromData() { renderWsTabs(); }
  2604. function _renderWsTabsDom() {
  2605. const container = $('wsTabs');
  2606. if (!container) return;
  2607. container.innerHTML = '';
  2608. const hasWorkspace = !!currentWorkDir;
  2609. const el = document.createElement('button');
  2610. el.type = 'button';
  2611. el.className = 'ws-current' + (hasWorkspace ? '' : ' empty');
  2612. el.title = hasWorkspace ? currentWorkDir : 'No workspace selected';
  2613. el.onclick = toggleWsPopover;
  2614. el.innerHTML = `<span class="ws-current-icon">${hasWorkspace ? '&#9679;' : '&#9675;'}</span><span class="ws-current-name" id="wsCurrentName">${escapeHtml(hasWorkspace ? path_basename(currentWorkDir) : 'Open Workspace')}</span>`;
  2615. container.appendChild(el);
  2616. setWorkspaceTriggerHighlight(!hasWorkspace);
  2617. }
  2618. /** Close a window instance by port */
  2619. async function closeWindowInstance(port, isCurrent) {
  2620. try {
  2621. await fetch(`/api/windows/${port}`, { method: 'DELETE' });
  2622. if (isCurrent) {
  2623. window.close();
  2624. } else {
  2625. await renderWsTabs();
  2626. }
  2627. } catch (e) { console.error('closeWindowInstance error:', e); }
  2628. }
  2629. /** Open a workspace in a new browser window */
  2630. async function openWorkspaceInNewWindow(dirPath) {
  2631. $('wsPopover').classList.remove('open');
  2632. setStatus('Opening new window...', 'yellow');
  2633. try {
  2634. if (isDesktopApp() && window.vlcodeDesktop?.openWorkspaceWindow) {
  2635. await window.vlcodeDesktop.openWorkspaceWindow({ dirPath });
  2636. setStatus('Ready', 'green');
  2637. return;
  2638. }
  2639. const data = await api('/api/windows/open', { method: 'POST', body: JSON.stringify({ dirPath }) });
  2640. if (data.url) {
  2641. const popup = window.open(data.url, `vlcode_${data.port}`);
  2642. if (!popup) {
  2643. 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.`);
  2644. setStatus('Popup blocked by browser', 'red');
  2645. return;
  2646. }
  2647. }
  2648. // refresh tab bar after a short delay (new process needs time to register)
  2649. setTimeout(renderWsTabs, 2000);
  2650. setStatus('Ready', 'green');
  2651. } catch (e) { setStatus('Failed to open window: ' + e.message, 'red'); }
  2652. }
  2653. async function loadWorkspaces() {
  2654. try {
  2655. const data = await api('/api/workspaces');
  2656. // API returns a plain array; mark active entry by comparing to currentWorkDir
  2657. const workspaces = Array.isArray(data) ? data : (data.workspaces || []);
  2658. const wsWithActive = workspaces.map(w => ({ ...w, active: w.path === currentWorkDir }));
  2659. renderWsListInPopover(wsWithActive);
  2660. const active = wsWithActive.find(w => w.active);
  2661. const name = active ? active.name : (currentWorkDir ? path_basename(currentWorkDir) : '');
  2662. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = name;
  2663. // Update workspace tabs
  2664. await renderWsTabs();
  2665. } catch {}
  2666. }
  2667. function renderWsListInPopover(workspaces) {
  2668. const list = $('wsList');
  2669. list.innerHTML = '';
  2670. for (const ws of workspaces) {
  2671. const isCurrent = ws.path === currentWorkDir;
  2672. const div = document.createElement('div');
  2673. div.className = 'ws-item' + (isCurrent ? ' active' : '');
  2674. 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}')">&times;</span>`;
  2675. if (isCurrent) {
  2676. // Already open in this window — no action
  2677. div.title = 'Current window';
  2678. } else {
  2679. div.onclick = () => switchWorkspace(ws.path);
  2680. div.title = 'Open in this window';
  2681. }
  2682. list.appendChild(div);
  2683. }
  2684. }
  2685. function setInternalFilesVisible(visible, { reload = true, persist = true } = {}) {
  2686. showInternalFiles = !!visible;
  2687. const btn = $('toggleInternalFilesBtn');
  2688. if (btn) {
  2689. btn.classList.toggle('active', showInternalFiles);
  2690. btn.title = showInternalFiles
  2691. ? 'Hide internal files and generated artifacts'
  2692. : 'Show internal files and generated artifacts';
  2693. }
  2694. if (persist) localStorage.setItem('vl-code-show-internal', showInternalFiles ? '1' : '0');
  2695. if (reload && currentWorkDir) loadFileTree();
  2696. }
  2697. function toggleInternalFiles() {
  2698. setInternalFilesVisible(!showInternalFiles);
  2699. }
  2700. function toggleWsPopover() {
  2701. const pop = $('wsPopover');
  2702. pop.classList.toggle('open');
  2703. if (pop.classList.contains('open') && !_browseCurrentDir) {
  2704. const startDir = currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '';
  2705. browseDir(startDir || '');
  2706. }
  2707. }
  2708. document.addEventListener('click', e => {
  2709. if (!e.target.closest('.ws-popover') && !e.target.closest('.ws-current')) $('wsPopover').classList.remove('open');
  2710. if (!e.target.closest('.mention-dropdown') && !e.target.closest('#chatInput')) $('mentionDropdown').classList.remove('open');
  2711. if (!e.target.closest('.ca-menu')) closeChatMoreMenu();
  2712. });
  2713. // ===================== NEW VL PROJECT =====================
  2714. function toggleNewProjectForm() {
  2715. const form = $('wsNewProjectForm');
  2716. const visible = form.style.display !== 'none';
  2717. form.style.display = visible ? 'none' : 'block';
  2718. if (!visible) {
  2719. // Set location to current browse directory or parent of workDir
  2720. const loc = _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
  2721. $('newProjectLocation').value = loc || '';
  2722. $('newProjectName').value = '';
  2723. $('newProjectError').style.display = 'none';
  2724. $('newProjectName').focus();
  2725. }
  2726. }
  2727. async function pickNewProjectLocation() {
  2728. if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) return;
  2729. try {
  2730. const defaultPath = $('newProjectLocation').value.trim() || _browseCurrentDir || currentWorkDir || '';
  2731. const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath });
  2732. if (!picked?.canceled && picked?.path) {
  2733. $('newProjectLocation').value = picked.path;
  2734. }
  2735. } catch (e) {
  2736. $('newProjectError').textContent = e.message || 'Failed to choose location';
  2737. $('newProjectError').style.display = 'block';
  2738. }
  2739. }
  2740. async function openWorkspacePicker() {
  2741. if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) {
  2742. browseDir(_browseCurrentDir || '');
  2743. return;
  2744. }
  2745. try {
  2746. const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath: _browseCurrentDir || currentWorkDir || '' });
  2747. if (!picked?.canceled && picked?.path) {
  2748. await switchWorkspace(picked.path);
  2749. }
  2750. } catch (e) {
  2751. setStatus('Failed to choose folder: ' + (e.message || e), 'red');
  2752. }
  2753. }
  2754. async function createNewProject() {
  2755. const name = $('newProjectName').value.trim();
  2756. if (!name) { $('newProjectError').textContent = 'Project name is required'; $('newProjectError').style.display = 'block'; return; }
  2757. let parentDir = $('newProjectLocation').value.trim() || _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
  2758. if (!parentDir && isDesktopApp()) {
  2759. await pickNewProjectLocation();
  2760. parentDir = $('newProjectLocation').value.trim();
  2761. }
  2762. if (!parentDir) { $('newProjectError').textContent = 'Browse to a location first'; $('newProjectError').style.display = 'block'; return; }
  2763. try {
  2764. $('newProjectError').style.display = 'none';
  2765. const res = await fetch('/api/workspaces/create-project', {
  2766. method: 'POST', headers: { 'Content-Type': 'application/json' },
  2767. body: JSON.stringify({ parentDir, projectName: name })
  2768. });
  2769. const data = await res.json();
  2770. if (!res.ok) { $('newProjectError').textContent = data.error || 'Failed'; $('newProjectError').style.display = 'block'; return; }
  2771. // Open new project in the current window
  2772. $('wsNewProjectForm').style.display = 'none';
  2773. $('wsPopover').classList.remove('open');
  2774. await switchWorkspace(data.path);
  2775. addMsg('assistant', `**New VL project created:** ${data.name}\nLocation: ${data.path}`);
  2776. } catch (e) {
  2777. $('newProjectError').textContent = e.message || 'Failed to create project';
  2778. $('newProjectError').style.display = 'block';
  2779. }
  2780. }
  2781. // ===================== FILE TREE DRAG & DROP =====================
  2782. function handleFileTreeDragOver(e) {
  2783. e.preventDefault();
  2784. e.stopPropagation(); // prevent global drop overlay
  2785. e.dataTransfer.dropEffect = 'copy';
  2786. const overlay = $('sidebarDropOverlay');
  2787. if (overlay) overlay.style.display = 'block';
  2788. }
  2789. function handleFileTreeDragLeave(e) {
  2790. const overlay = $('sidebarDropOverlay');
  2791. // Only hide if truly leaving the file tree (not entering a child)
  2792. const tree = e.currentTarget;
  2793. if (!tree.contains(e.relatedTarget)) {
  2794. if (overlay) overlay.style.display = 'none';
  2795. }
  2796. }
  2797. async function handleFileTreeDrop(e) {
  2798. e.preventDefault();
  2799. e.stopPropagation(); // prevent global drop overlay
  2800. dragCounter = 0; $('dropOverlay').classList.remove('active'); // ensure global overlay clears
  2801. const overlay = $('sidebarDropOverlay');
  2802. if (overlay) overlay.style.display = 'none';
  2803. const items = e.dataTransfer.items;
  2804. if (!items || items.length === 0) return;
  2805. const filesToUpload = [];
  2806. // Accept all common code/text file types — preserve real folder structure
  2807. 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'];
  2808. // Process dropped items — preserve original folder structure
  2809. const readEntries = async (entry, basePath) => {
  2810. if (entry.isFile) {
  2811. return new Promise((resolve) => {
  2812. entry.file(f => {
  2813. // Accept files matching code extensions or without extension (Makefile, Dockerfile, etc.)
  2814. const ext = f.name.includes('.') ? '.' + f.name.split('.').pop().toLowerCase() : '';
  2815. if (ext && !codeExts.includes(ext)) { resolve(); return; }
  2816. const reader = new FileReader();
  2817. reader.onload = () => {
  2818. // Preserve the real relative path from the drop — no auto-mapping
  2819. const relPath = basePath ? basePath + '/' + f.name : f.name;
  2820. filesToUpload.push({ path: relPath, content: reader.result });
  2821. resolve();
  2822. };
  2823. reader.readAsText(f);
  2824. });
  2825. });
  2826. } else if (entry.isDirectory) {
  2827. const dirReader = entry.createReader();
  2828. return new Promise((resolve) => {
  2829. dirReader.readEntries(async (entries) => {
  2830. for (const sub of entries) {
  2831. await readEntries(sub, basePath ? basePath + '/' + entry.name : entry.name);
  2832. }
  2833. resolve();
  2834. });
  2835. });
  2836. }
  2837. };
  2838. setStatus('Importing dropped files...', 'yellow');
  2839. try {
  2840. const promises = [];
  2841. for (let i = 0; i < items.length; i++) {
  2842. const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
  2843. if (entry) promises.push(readEntries(entry, ''));
  2844. }
  2845. await Promise.all(promises);
  2846. if (filesToUpload.length === 0) {
  2847. setStatus('No files to import', 'yellow');
  2848. return;
  2849. }
  2850. // Upload files to server
  2851. const res = await fetch('/api/upload-folder', {
  2852. method: 'POST', headers: { 'Content-Type': 'application/json' },
  2853. body: JSON.stringify({ files: filesToUpload })
  2854. });
  2855. const data = await res.json();
  2856. if (data.ok) {
  2857. await loadFileTree();
  2858. setStatus(`Imported ${data.filesWritten} file(s)`, 'green');
  2859. addMsg('assistant', `**Imported ${data.filesWritten} file(s)** via drag & drop:\n${data.paths.map(p => ' - ' + p).join('\n')}`);
  2860. } else {
  2861. setStatus(data.error || 'Import failed', 'red');
  2862. }
  2863. } catch (e) {
  2864. setStatus('Drop import error: ' + e.message, 'red');
  2865. }
  2866. }
  2867. async function addWorkspace() {
  2868. const dirPath = $('wsAddPath').value.trim();
  2869. if (!dirPath) return;
  2870. $('wsAddPath').value = '';
  2871. // If user typed a path, try browsing to it first; if valid dir, navigate the browser
  2872. try {
  2873. const data = await api(`/api/browse-dir?path=${encodeURIComponent(dirPath)}`);
  2874. if (data.current) {
  2875. browseDir(data.current);
  2876. return;
  2877. }
  2878. } catch {}
  2879. // Fallback: open in this window
  2880. await switchWorkspace(dirPath);
  2881. }
  2882. async function switchWorkspace(dirPath) {
  2883. setStatus('Switching workspace...', 'yellow');
  2884. $('wsPopover').classList.remove('open');
  2885. try {
  2886. // 1. Save current workspace's chat history before leaving
  2887. saveChatState();
  2888. const oldWorkDir = currentWorkDir;
  2889. const switchRes = await fetch('/api/workspaces/switch', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ dirPath }) });
  2890. if (!switchRes.ok) { const err = await switchRes.json().catch(() => ({})); throw new Error(err.error || 'Server switch failed'); }
  2891. // 2. Close all open file tabs (they belong to old workspace)
  2892. openFiles.clear();
  2893. currentFile = null;
  2894. renderTabs();
  2895. $('editor').style.display = 'none';
  2896. $('codePreview').style.display = 'none';
  2897. $('mdPreview').style.display = 'none';
  2898. $('iframeContainer').style.display = 'none';
  2899. $('editorPlaceholder').style.display = 'block';
  2900. $('currentFile').textContent = '';
  2901. // 3. Destroy all cached iframes (metadata/workflow from old workspace)
  2902. const container = $('iframeContainer');
  2903. container.innerHTML = '';
  2904. // 4. Switch back to code mode
  2905. currentMode = 'code';
  2906. document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === 'code'));
  2907. // 5. Reload everything from the new workspace
  2908. await loadProjectInfo();
  2909. await loadFileTree();
  2910. await loadWorkspaces();
  2911. await refreshDocIdSettings();
  2912. updateContext();
  2913. // 5b. Reload preview URLs and GID for the new workspace
  2914. previewUrls = {};
  2915. $('previewUrlsPanel').style.display = 'none';
  2916. $('previewUrlsList').innerHTML = '';
  2917. $('previewUrlLabel').textContent = '';
  2918. if ($('cloudGid')) $('cloudGid').value = '';
  2919. loadPreviewUrlsFromProfile();
  2920. loadCloudGid();
  2921. // 6. Restore chat history from backend (or start fresh)
  2922. resetConversationState();
  2923. const wsRestored = await fetchChatStateFromServer();
  2924. if (!wsRestored) loadChatState(); // offline fallback
  2925. // 6b. Re-enable chat input (may have been disabled when no workspace was selected)
  2926. $('chatInput').disabled = false;
  2927. $('chatInput').placeholder = 'Describe changes, @mention files, /s...';
  2928. $('chatSend').disabled = false;
  2929. // 7. Auto-open the first VL file in the new workspace
  2930. autoOpenFirstFile();
  2931. setStatus('Ready', 'green');
  2932. } catch(e) { console.error('switchWorkspace error:', e); setStatus('Switch failed: ' + (e.message || e), 'red'); }
  2933. }
  2934. /** Close current workspace — return to "Open File" initial state */
  2935. async function closeWorkspace() {
  2936. $('wsPopover').classList.remove('open');
  2937. try {
  2938. await fetch('/api/workspaces/close', { method:'POST' });
  2939. } catch {}
  2940. // Clear editor
  2941. openFiles.clear();
  2942. currentFile = null;
  2943. renderTabs();
  2944. $('editor').style.display = 'none';
  2945. $('codePreview').style.display = 'none';
  2946. $('mdPreview').style.display = 'none';
  2947. $('iframeContainer').style.display = 'none';
  2948. $('iframeContainer').innerHTML = '';
  2949. $('editorPlaceholder').style.display = 'block';
  2950. $('currentFile').textContent = '';
  2951. previewUrls = {};
  2952. $('previewUrlsPanel').style.display = 'none';
  2953. $('previewUrlsList').innerHTML = '';
  2954. $('previewUrlLabel').textContent = '';
  2955. if ($('cloudGid')) $('cloudGid').value = '';
  2956. // Reset workspace display
  2957. $('projectInfo').textContent = '';
  2958. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2959. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2960. _tabWorkspaceName = '';
  2961. setTabStatus(_tabStatus);
  2962. // Clear file tree
  2963. $('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>';
  2964. currentWorkDir = '';
  2965. resetConversationState();
  2966. $('chatInput').disabled = false;
  2967. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2968. $('chatSend').disabled = false;
  2969. renderWsTabs();
  2970. await refreshDocIdSettings();
  2971. switchMode('docs');
  2972. setStatus('Workspace closed', 'green');
  2973. }
  2974. async function deleteWorkspace(id) {
  2975. await fetch(`/api/workspaces/${id}`, { method:'DELETE' });
  2976. await loadWorkspaces();
  2977. }
  2978. function path_basename(p) { return p ? p.split('/').pop() || p : 'No workspace'; }
  2979. function toPascalProjectName(raw) {
  2980. if (!raw) return '';
  2981. const cleaned = String(raw)
  2982. .replace(/\.[^.]+$/, '')
  2983. .replace(/[`"'“”‘’]/g, ' ')
  2984. .replace(/[^A-Za-z0-9]+/g, ' ')
  2985. .trim();
  2986. if (!cleaned) return '';
  2987. const merged = cleaned.split(/\s+/).filter(Boolean)
  2988. .map(part => part.charAt(0).toUpperCase() + part.slice(1))
  2989. .join('');
  2990. return /^[A-Z][A-Za-z0-9]*$/.test(merged) ? merged : '';
  2991. }
  2992. async function ensureWorkspaceForImport(suggestedName) {
  2993. if (currentWorkDir) return true;
  2994. const projectName = toPascalProjectName(suggestedName) || `ImportedProject${Date.now().toString().slice(-6)}`;
  2995. setStatus(`Creating workspace ${projectName}...`, 'yellow');
  2996. const res = await fetch('/api/workspaces/create-project', {
  2997. method: 'POST',
  2998. headers: { 'Content-Type': 'application/json' },
  2999. body: JSON.stringify({ projectName }),
  3000. });
  3001. const data = await res.json();
  3002. if (!res.ok || !data.path) throw new Error(data.error || 'Create workspace failed');
  3003. await switchWorkspace(data.path);
  3004. return true;
  3005. }
  3006. // ===================== VL REFERENCE DOCS =====================
  3007. async function loadVLDocs() {
  3008. try {
  3009. const data = await api('/api/vl-docs');
  3010. renderVLDocs(data.docs || []);
  3011. } catch {}
  3012. }
  3013. function renderVLDocs(docs) {
  3014. const list = $('vlDocsList');
  3015. if (!list) return;
  3016. if (!docs.length) {
  3017. list.innerHTML = '<div style="padding:4px 12px;font-size:10px;color:var(--text2);">No docs cached. Click &#8635; to sync from DocCenter.</div>';
  3018. return;
  3019. }
  3020. list.innerHTML = '';
  3021. for (const doc of docs) {
  3022. const el = document.createElement('div');
  3023. el.className = 'pc-file';
  3024. el.title = doc.path || doc.name;
  3025. el.innerHTML = `<span onclick="viewVLDoc(${doc.id})" style="cursor:pointer;flex:1;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(doc.name)}</span>` +
  3026. `<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)'}">` +
  3027. `${doc.active ? '&#9679;' : '&#9675;'}</button>`;
  3028. list.appendChild(el);
  3029. }
  3030. }
  3031. async function syncVLDocs() {
  3032. setStatus('Syncing VL docs from DocCenter...', 'yellow');
  3033. try {
  3034. const res = await fetch('/api/vl-docs/sync', { method: 'POST' });
  3035. const data = await res.json();
  3036. if (data.error) { setStatus('Sync failed: ' + data.error, 'red'); return; }
  3037. renderVLDocs(data.docs || []);
  3038. $('vlDocsList').style.display = 'block';
  3039. setStatus(`Synced ${data.synced} VL docs`, 'green');
  3040. } catch (e) { setStatus('Sync failed: ' + e.message, 'red'); }
  3041. }
  3042. async function toggleVLDoc(docId, active) {
  3043. try {
  3044. await fetch('/api/vl-docs/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ docId, active }) });
  3045. await loadVLDocs();
  3046. } catch {}
  3047. }
  3048. async function viewVLDoc(docId) {
  3049. try {
  3050. const data = await api('/api/vl-docs');
  3051. const doc = (data.docs || []).find(d => d.id === docId);
  3052. if (!doc) return;
  3053. // Open doc content in editor as read-only preview
  3054. const res = await fetch(`/api/vl-docs/content?file=${encodeURIComponent(doc.file)}`);
  3055. const content = await res.text();
  3056. addMsg('assistant', `**${doc.name}**\n\`\`\`\n${content.substring(0, 2000)}\n\`\`\`${content.length > 2000 ? '\n...(truncated)' : ''}`);
  3057. } catch {}
  3058. }
  3059. // ===================== DIRECTORY BROWSER =====================
  3060. let _browseCurrentDir = '';
  3061. async function browseDir(dirPath) {
  3062. try {
  3063. const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
  3064. const data = await api(`/api/browse-dir${params}`);
  3065. _browseCurrentDir = data.current;
  3066. $('browsePath').textContent = data.current;
  3067. $('browsePath').title = data.current;
  3068. const list = $('browseList');
  3069. list.innerHTML = '';
  3070. if (!data.dirs.length) {
  3071. list.innerHTML = '<div style="padding:8px 12px;font-size:10px;color:var(--text2);">No subdirectories</div>';
  3072. return;
  3073. }
  3074. for (const d of data.dirs) {
  3075. const div = document.createElement('div');
  3076. div.className = 'ws-browse-item' + (d.isVL ? ' is-vl' : '');
  3077. div.innerHTML = `<span class="dir-icon">${d.isVL ? '&#9670;' : '&#128193;'}</span><span class="dir-name">${escapeHtml(d.name)}</span>${d.isVL ? '<span class="dir-vl">VL</span>' : ''}`;
  3078. div.onclick = (e) => { e.stopPropagation(); browseDir(d.path); };
  3079. div.ondblclick = (e) => { e.stopPropagation(); switchWorkspace(d.path); };
  3080. 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)');
  3081. list.appendChild(div);
  3082. }
  3083. } catch (e) {
  3084. $('browseList').innerHTML = `<div style="padding:8px 12px;font-size:10px;color:var(--red);">Error: ${escapeHtml(e.message || 'Failed to browse')}</div>`;
  3085. }
  3086. }
  3087. function browseDirUp() {
  3088. if (!_browseCurrentDir) return;
  3089. const parent = _browseCurrentDir.split('/').slice(0, -1).join('/') || '/';
  3090. browseDir(parent);
  3091. }
  3092. function selectBrowseDir() {
  3093. if (_browseCurrentDir) switchWorkspace(_browseCurrentDir);
  3094. }
  3095. // Directory browser auto-load is now handled inside toggleWsPopover()
  3096. // ===================== RESTART BACKEND =====================
  3097. async function restartBackend() {
  3098. const btn = $('restartBtn');
  3099. if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
  3100. try {
  3101. await fetch('/api/restart', { method: 'POST' });
  3102. } catch (_) { /* connection will drop */ }
  3103. // Poll until server is back
  3104. const poll = async () => {
  3105. for (let i = 0; i < 30; i++) {
  3106. await new Promise(r => setTimeout(r, 1000));
  3107. try {
  3108. const resp = await fetch('/api/health');
  3109. if (resp.ok) {
  3110. if (btn) { btn.disabled = false; btn.textContent = '↻'; }
  3111. appendLog('system', 'Backend restarted successfully.');
  3112. location.reload();
  3113. return;
  3114. }
  3115. } catch (_) { /* still down */ }
  3116. }
  3117. if (btn) { btn.disabled = false; btn.textContent = '↻'; }
  3118. appendLog('error', 'Backend restart timed out. Please restart manually.');
  3119. };
  3120. poll();
  3121. }
  3122. // ===================== SETTINGS =====================
  3123. const DOC_ID_CORE_FIELDS = [
  3124. { alias: 'vlSyntax', label: 'VL Syntax', path: 1 },
  3125. { alias: 'theme', label: 'Theme', path: 2 },
  3126. ];
  3127. const DOC_ID_WORKFLOW_FIELDS = [
  3128. { alias: 'workflow3File', label: '3-File CodeGen', path: 30 },
  3129. { alias: 'workflow6File', label: '6-File CodeGen', path: 60 },
  3130. { alias: 'workflow9File', label: '9-File CodeGen', path: 90 },
  3131. { alias: 'workflowMetaDirect', label: 'MetaDirect', path: 110 },
  3132. { alias: 'workflowAddPage', label: 'Add Page', path: 120 },
  3133. { alias: 'workflowAddService', label: 'Add Service', path: 130 },
  3134. { alias: 'workflowThemeCustomize', label: 'Theme Customize', path: 140 },
  3135. { alias: 'workflowIncrementalUpdate', label: 'Incremental Update', path: 141 },
  3136. { alias: 'workflowCompileFix', label: 'Compile Fix', path: 142 },
  3137. ];
  3138. const DOC_ID_LOCKED_FIELDS = [
  3139. { alias: 'workflowSpec', label: 'Workflow Spec', path: 3 },
  3140. { alias: 'metaSpec', label: 'Meta Spec', path: 4 },
  3141. ];
  3142. const DOC_ID_EDITABLE_FIELDS = [...DOC_ID_CORE_FIELDS, ...DOC_ID_WORKFLOW_FIELDS];
  3143. let _docIdSettingsSnapshot = { docIdOverrides: {}, coreDocIds: {} };
  3144. function docIdInputId(prefix, alias) {
  3145. return `${prefix}DocId_${alias}`;
  3146. }
  3147. function getDocIdValue(settings, alias) {
  3148. const editable = parseInt(settings?.docIdOverrides?.[alias], 10);
  3149. if (Number.isInteger(editable) && editable > 0) return editable;
  3150. const locked = parseInt(settings?.coreDocIds?.[alias], 10);
  3151. if (Number.isInteger(locked) && locked > 0) return locked;
  3152. return '';
  3153. }
  3154. function renderDocIdFieldGroup(containerId, prefix, fields, settings, { locked = false } = {}) {
  3155. const container = $(containerId);
  3156. if (!container) return;
  3157. container.innerHTML = fields.map((field) => {
  3158. const value = getDocIdValue(settings, field.alias);
  3159. return `
  3160. <label class="settings-doc-card${locked ? ' is-locked' : ''}">
  3161. <span class="settings-doc-title">${escapeHtml(field.label)}</span>
  3162. <span class="settings-doc-meta">Path ${field.path}</span>
  3163. <input type="number" id="${docIdInputId(prefix, field.alias)}" min="1" placeholder="Doc ID" value="${value}" ${locked ? 'disabled' : ''}>
  3164. </label>
  3165. `;
  3166. }).join('');
  3167. }
  3168. function applyDocIdSettings(settings = {}) {
  3169. _docIdSettingsSnapshot = {
  3170. docIdOverrides: { ...(settings.docIdOverrides || {}) },
  3171. coreDocIds: { ...(settings.coreDocIds || {}) },
  3172. };
  3173. renderDocIdFieldGroup('settingsDocIdCoreGrid', 'settings', DOC_ID_CORE_FIELDS, settings);
  3174. renderDocIdFieldGroup('settingsDocIdWorkflowGrid', 'settings', DOC_ID_WORKFLOW_FIELDS, settings);
  3175. renderDocIdFieldGroup('settingsDocIdLockedGrid', 'settings', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
  3176. renderDocIdFieldGroup('docIdCoreGrid', 'sidebar', DOC_ID_CORE_FIELDS, settings);
  3177. renderDocIdFieldGroup('docIdWorkflowGrid', 'sidebar', DOC_ID_WORKFLOW_FIELDS, settings);
  3178. renderDocIdFieldGroup('docIdLockedGrid', 'sidebar', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
  3179. }
  3180. function collectDocIdSettings(prefix) {
  3181. const out = {};
  3182. for (const field of DOC_ID_EDITABLE_FIELDS) {
  3183. const raw = $(docIdInputId(prefix, field.alias))?.value?.trim?.() || '';
  3184. const value = parseInt(raw, 10);
  3185. out[field.alias] = Number.isInteger(value) && value > 0 ? value : null;
  3186. }
  3187. return out;
  3188. }
  3189. async function refreshDocIdSettings() {
  3190. try {
  3191. const settings = await api('/api/settings');
  3192. applyDocIdSettings(settings);
  3193. return settings;
  3194. } catch {
  3195. return null;
  3196. }
  3197. }
  3198. function toggleDocIdConfigPanel() {
  3199. const body = $('docIdConfigBody');
  3200. if (!body) return;
  3201. body.style.display = body.style.display === 'none' ? 'block' : 'none';
  3202. }
  3203. function toggleDocWorkflowGrid(forceOpen = null) {
  3204. const grid = $('docIdWorkflowGrid');
  3205. const toggle = $('docWorkflowToggle');
  3206. if (!grid || !toggle) return;
  3207. const shouldOpen = forceOpen === null ? grid.style.display === 'none' : !!forceOpen;
  3208. grid.style.display = shouldOpen ? 'flex' : 'none';
  3209. toggle.innerHTML = shouldOpen ? '&#9660;' : '&#9654;';
  3210. }
  3211. async function saveDocIdConfigPanel() {
  3212. const body = {
  3213. docIdOverrides: collectDocIdSettings('sidebar'),
  3214. };
  3215. await fetch('/api/settings', {
  3216. method: 'POST',
  3217. headers: { 'Content-Type': 'application/json' },
  3218. body: JSON.stringify(body),
  3219. });
  3220. const updatedSettings = await refreshDocIdSettings();
  3221. if (updatedSettings) {
  3222. updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
  3223. }
  3224. setStatus('Document IDs saved', 'green');
  3225. }
  3226. async function openSettings() {
  3227. const s = await api('/api/settings');
  3228. $('settingsKey').value = '';
  3229. $('settingsKey').placeholder = 'sk-ant-api03-...';
  3230. $('settingsModel').value = s.model;
  3231. $('settingsMaxTokens').value = s.maxOutputTokens;
  3232. $('settingsWorkDir').value = s.workDir;
  3233. applyDocIdSettings(s);
  3234. // Show cloud connection status
  3235. $('settingsCloudStatus').textContent = _cloudConnected ? 'Connected' : 'Not connected';
  3236. $('settingsCloudStatus').style.color = _cloudConnected ? 'var(--green)' : 'var(--text2)';
  3237. renderProviderSettingsState(s);
  3238. // Load autotest settings
  3239. const at = s.autotest || {};
  3240. $('settingsHeadless').checked = !!at.headless;
  3241. $('settingsUseWorkflow').checked = at.useWorkflowEngine !== false;
  3242. $('settingsParallelBrowsers').value = at.parallelWorkers || 5;
  3243. $('settingsMaxCases').value = at.maxCases || 10;
  3244. $('settingsVersion').textContent = `VL-Code v${$('appVersion')?.textContent?.replace('v','') || '?'}`;
  3245. $('settingsModal').classList.add('open');
  3246. }
  3247. function closeSettings() { $('settingsModal').classList.remove('open'); }
  3248. document.querySelectorAll('input[name="settingsProvider"]').forEach((el) => {
  3249. el.addEventListener('change', () => {
  3250. renderProviderSettingsState({
  3251. ...(_settingsSnapshot || {}),
  3252. llmProvider: getSelectedSettingsProvider(),
  3253. });
  3254. });
  3255. });
  3256. function toggleKeyVisibility() {
  3257. const inp = $('settingsKey');
  3258. inp.type = inp.type === 'password' ? 'text' : 'password';
  3259. }
  3260. async function saveSettings() {
  3261. const body = {};
  3262. const key = $('settingsKey').value.trim();
  3263. if (key) body.apiKey = key;
  3264. body.llmProvider = getSelectedSettingsProvider();
  3265. body.model = $('settingsModel').value;
  3266. body.maxOutputTokens = parseInt($('settingsMaxTokens').value) || 16000;
  3267. body.autotest = {
  3268. headless: $('settingsHeadless').checked,
  3269. useWorkflowEngine: $('settingsUseWorkflow').checked,
  3270. parallelWorkers: parseInt($('settingsParallelBrowsers').value) || 5,
  3271. maxCases: parseInt($('settingsMaxCases').value) || 10,
  3272. };
  3273. body.docIdOverrides = collectDocIdSettings('settings');
  3274. await fetch('/api/settings', {
  3275. method: 'POST', headers: {'Content-Type':'application/json'},
  3276. body: JSON.stringify(body)
  3277. });
  3278. closeSettings();
  3279. await loadProjectInfo();
  3280. // Update LLM provider badge
  3281. const updatedSettings = await refreshDocIdSettings() || await api('/api/settings');
  3282. updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
  3283. setStatus('Settings saved', 'green');
  3284. }
  3285. // ===================== CONTEXT EXCLUSION =====================
  3286. let _lastBackendMsgCount = 0;
  3287. async function toggleMsgContext(btnEl) {
  3288. // Find the parent user message with turn boundaries
  3289. const msgEl = btnEl.closest('.msg');
  3290. if (!msgEl) return;
  3291. // Walk up/down to find the user message that has turnStart/turnEnd
  3292. let turnEl = msgEl;
  3293. if (!turnEl.dataset.turnStart) {
  3294. // This is an assistant msg or tool group — find sibling user msg
  3295. let prev = turnEl.previousElementSibling;
  3296. while (prev && !prev.dataset.turnStart) prev = prev.previousElementSibling;
  3297. if (prev) turnEl = prev;
  3298. else return; // can't find turn boundary
  3299. }
  3300. const startIdx = parseInt(turnEl.dataset.turnStart);
  3301. const endIdx = parseInt(turnEl.dataset.turnEnd);
  3302. if (isNaN(startIdx) || isNaN(endIdx)) return;
  3303. try {
  3304. const res = await fetch('/api/context/toggle-exclude', {
  3305. method: 'POST',
  3306. headers: { 'Content-Type': 'application/json' },
  3307. body: JSON.stringify({ startIdx, endIdx, chatId: activeConvId }),
  3308. });
  3309. const data = await res.json();
  3310. if (!data.ok) return;
  3311. const isExcluded = data.nowExcluded;
  3312. // Mark all messages in this turn visually
  3313. let el = turnEl;
  3314. while (el) {
  3315. if (el.classList.contains('msg') || el.classList.contains('tool-group') || el.classList.contains('thinking-block')) {
  3316. el.classList.toggle('excluded-msg', isExcluded);
  3317. const ctxBtn = el.querySelector('.msg-ctx-toggle');
  3318. if (ctxBtn) ctxBtn.classList.toggle('excluded', isExcluded);
  3319. }
  3320. el = el.nextElementSibling;
  3321. // Stop at the next user message (start of next turn) or end
  3322. if (el?.classList.contains('msg') && el?.querySelector('.label')?.textContent === 'user') break;
  3323. }
  3324. // Update context bar
  3325. if (data.usage) {
  3326. const pct = Math.round(data.usage.usedTokens / data.usage.maxTokens * 100);
  3327. $('ctxLabel').textContent = `${pct}%`;
  3328. $('ctxBar').style.width = pct + '%';
  3329. $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
  3330. }
  3331. } catch (e) {
  3332. console.error('toggleMsgContext failed:', e);
  3333. }
  3334. }
  3335. // ===================== AUTH STATUS (header bar) =====================
  3336. function updateAuthStatus(connected, userName) {
  3337. const dot = $('authDot');
  3338. const label = $('authLabel');
  3339. if (connected) {
  3340. dot.classList.add('ok');
  3341. label.textContent = userName || 'Connected';
  3342. label.className = 'auth-name';
  3343. } else {
  3344. dot.classList.remove('ok');
  3345. label.textContent = 'Not logged in';
  3346. label.className = 'auth-label';
  3347. }
  3348. }
  3349. function onAuthStatusClick() {
  3350. if (_cloudConnected) {
  3351. // Toggle cloud panel to show full status + logout option
  3352. toggleCloudPanel();
  3353. } else {
  3354. openCloudLogin();
  3355. }
  3356. }
  3357. // ===================== CLOUD PLATFORM =====================
  3358. let _cloudConnected = false;
  3359. function toggleCloudPanel() {
  3360. const panel = $('cloudPanel');
  3361. const visible = panel.style.display !== 'none';
  3362. panel.style.display = visible ? 'none' : 'block';
  3363. if (!visible) checkCloudStatus();
  3364. }
  3365. async function checkCloudStatus() {
  3366. try {
  3367. const data = await api('/api/cloud/status');
  3368. if (data.connected) {
  3369. showCloudConnected(data.user);
  3370. } else {
  3371. showCloudDisconnected();
  3372. }
  3373. } catch {
  3374. showCloudDisconnected();
  3375. }
  3376. }
  3377. function normalizeProjectProfile(data) {
  3378. if (!data || typeof data !== 'object') return {};
  3379. return (data.profile && typeof data.profile === 'object') ? data.profile : data;
  3380. }
  3381. function getProfileGid(profile) {
  3382. const gid = profile?.groupId ?? profile?.compileGid ?? '';
  3383. return gid ? String(gid) : '';
  3384. }
  3385. function showCloudConnected(user) {
  3386. _cloudConnected = true;
  3387. $('cloudLoginPrompt').style.display = 'none';
  3388. $('cloudConnected').style.display = 'block';
  3389. $('cloudDot').classList.add('connected');
  3390. $('cloudBtn').classList.add('connected');
  3391. const name = user?.name || user?.nickName || 'User';
  3392. const company = user?.companyName || '';
  3393. $('cloudUserInfo').innerHTML = `<span class="cu-name">${name}</span>` +
  3394. (company ? `<span class="cu-company">${company}</span>` : '');
  3395. // Update header auth status
  3396. updateAuthStatus(true, name + (company ? ` (${company})` : ''));
  3397. // Load GID from profile
  3398. loadCloudGid();
  3399. loadCloudApps();
  3400. }
  3401. function showCloudDisconnected() {
  3402. _cloudConnected = false;
  3403. $('cloudLoginPrompt').style.display = 'block';
  3404. $('cloudConnected').style.display = 'none';
  3405. $('cloudDot').classList.remove('connected');
  3406. $('cloudBtn').classList.remove('connected');
  3407. updateAuthStatus(false);
  3408. }
  3409. async function loadCloudGid() {
  3410. try {
  3411. const profile = normalizeProjectProfile(await api('/api/profile'));
  3412. const gid = getProfileGid(profile);
  3413. if (gid) $('cloudGid').value = gid;
  3414. } catch {}
  3415. }
  3416. async function createCloudProject() {
  3417. const projectName = prompt('Cloud project name:', document.getElementById('sidebarProjectName')?.textContent || 'VLCode-Project');
  3418. if (!projectName) return;
  3419. showCloudSyncStatus('Creating cloud project...', 'syncing');
  3420. try {
  3421. const data = await api('/api/cloud/create-project', { method: 'POST', body: JSON.stringify({ name: projectName }) });
  3422. if (data.error) {
  3423. showCloudSyncStatus('Create failed: ' + data.error, 'error');
  3424. 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.`);
  3425. return;
  3426. }
  3427. $('cloudGid').value = data.gid;
  3428. // Save GID to Config/ProjectConfig
  3429. await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) });
  3430. showCloudSyncStatus(`Project created (GID: ${data.gid})`, 'ok');
  3431. setStatus(`Cloud project created: GID ${data.gid}`, 'green');
  3432. addMsg('assistant', `**Cloud project created** — GID: \`${data.gid}\`\n\nGID saved to \`Config/ProjectConfig\`. You can now click **Compile** to push files and build.`);
  3433. loadCloudApps();
  3434. } catch (e) {
  3435. showCloudSyncStatus('Error: ' + e.message, 'error');
  3436. }
  3437. }
  3438. async function loadCloudApps() {
  3439. try {
  3440. const data = await api('/api/cloud/apps?limit=20');
  3441. const list = $('cloudAppsList');
  3442. list.innerHTML = '';
  3443. if (!data.apps?.length) {
  3444. list.innerHTML = '<div style="padding:4px 12px;font-size:9px;color:var(--text2);">No cloud apps</div>';
  3445. return;
  3446. }
  3447. for (const app of data.apps) {
  3448. const el = document.createElement('div');
  3449. el.className = 'cloud-app-item';
  3450. el.innerHTML = `<span class="ca-title">${app.title || 'Untitled'}</span><span class="ca-gid">GID:${app.gid}</span>`;
  3451. el.onclick = () => {
  3452. $('cloudGid').value = app.gid;
  3453. setStatus(`Selected cloud workspace: ${app.title} (GID:${app.gid})`, 'green');
  3454. };
  3455. list.appendChild(el);
  3456. }
  3457. } catch {}
  3458. }
  3459. function openCloudLogin() {
  3460. $('cloudLoginError').style.display = 'none';
  3461. $('cloudUsername').value = '';
  3462. $('cloudPassword').value = '';
  3463. $('cloudCompany').value = '';
  3464. $('cloudDirectCookie').value = '';
  3465. switchLoginTab('enterprise');
  3466. $('cloudLoginModal').classList.add('open');
  3467. $('cloudUsername').focus();
  3468. // Load Google Identity Services
  3469. initGoogleSignIn();
  3470. }
  3471. function closeCloudLogin() {
  3472. $('cloudLoginModal').classList.remove('open');
  3473. }
  3474. function switchLoginTab(tab) {
  3475. document.querySelectorAll('.cl-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
  3476. $('clEnterprise').style.display = tab === 'enterprise' ? 'block' : 'none';
  3477. $('clGoogle').style.display = tab === 'google' ? 'block' : 'none';
  3478. $('clToken').style.display = tab === 'token' ? 'block' : 'none';
  3479. $('cloudLoginError').style.display = 'none';
  3480. }
  3481. function showLoginError(msg) {
  3482. $('cloudLoginError').textContent = msg;
  3483. $('cloudLoginError').style.display = 'block';
  3484. }
  3485. // --- Enterprise Login ---
  3486. async function doEnterpriseLogin() {
  3487. const btn = $('cloudLoginBtn');
  3488. btn.disabled = true;
  3489. btn.textContent = 'Logging in...';
  3490. $('cloudLoginError').style.display = 'none';
  3491. try {
  3492. const username = $('cloudUsername').value.trim();
  3493. const password = $('cloudPassword').value;
  3494. const companyName = $('cloudCompany').value.trim();
  3495. if (!username || !password) { showLoginError('Email and password required'); return; }
  3496. const res = await fetch('/api/cloud/login', {
  3497. method: 'POST',
  3498. headers: { 'Content-Type': 'application/json' },
  3499. body: JSON.stringify({ username, password, companyName }),
  3500. });
  3501. const data = await res.json();
  3502. if (data.ok) {
  3503. closeCloudLogin();
  3504. showCloudConnected(data.user);
  3505. setStatus('Cloud connected', 'green');
  3506. $('cloudPanel').style.display = 'block';
  3507. } else {
  3508. showLoginError(data.error || 'Login failed');
  3509. }
  3510. } catch (e) {
  3511. showLoginError(e.message);
  3512. } finally {
  3513. btn.disabled = false;
  3514. btn.textContent = 'Login';
  3515. }
  3516. }
  3517. // --- Google Sign-In ---
  3518. const GOOGLE_CLIENT_ID = '877956091268-3kjo5pbn2hptvt8s8q8l82mqlbs2fa3l.apps.googleusercontent.com';
  3519. let _gsiLoaded = false;
  3520. function initGoogleSignIn() {
  3521. if (_gsiLoaded) return;
  3522. // Load Google Identity Services script
  3523. if (!document.getElementById('gsi-script')) {
  3524. const script = document.createElement('script');
  3525. script.id = 'gsi-script';
  3526. script.src = 'https://accounts.google.com/gsi/client';
  3527. script.onload = () => { _gsiLoaded = true; renderGoogleButton(); };
  3528. script.onerror = () => {
  3529. $('googleSignInBtn').style.display = 'none';
  3530. $('googleSignInFallback').style.display = 'block';
  3531. };
  3532. document.head.appendChild(script);
  3533. } else if (window.google?.accounts) {
  3534. renderGoogleButton();
  3535. }
  3536. }
  3537. function renderGoogleButton() {
  3538. try {
  3539. google.accounts.id.initialize({
  3540. client_id: GOOGLE_CLIENT_ID,
  3541. callback: handleGoogleCredential,
  3542. auto_select: false,
  3543. });
  3544. google.accounts.id.renderButton($('googleSignInBtn'), {
  3545. theme: 'filled_black',
  3546. size: 'large',
  3547. width: 300,
  3548. text: 'signin_with',
  3549. });
  3550. $('googleSignInBtn').style.display = 'inline-block';
  3551. $('googleSignInFallback').style.display = 'none';
  3552. } catch (e) {
  3553. console.warn('Google Sign-In render failed:', e);
  3554. $('googleSignInBtn').style.display = 'none';
  3555. $('googleSignInFallback').style.display = 'block';
  3556. }
  3557. }
  3558. async function handleGoogleCredential(response) {
  3559. // response.credential is a JWT ID token
  3560. const statusEl = $('googleLoginStatus');
  3561. statusEl.style.display = 'block';
  3562. statusEl.textContent = 'Authenticating with VL Platform...';
  3563. try {
  3564. // Decode JWT to get user info (base64url decode the payload)
  3565. const payload = JSON.parse(atob(response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  3566. const googleUser = {
  3567. email: payload.email,
  3568. name: payload.name,
  3569. id: payload.sub,
  3570. picture: payload.picture || '',
  3571. };
  3572. // Call our server to do the platform googleLoginOrRegister
  3573. const res = await fetch('/api/cloud/google-login', {
  3574. method: 'POST',
  3575. headers: { 'Content-Type': 'application/json' },
  3576. body: JSON.stringify(googleUser),
  3577. });
  3578. const data = await res.json();
  3579. if (data.ok) {
  3580. closeCloudLogin();
  3581. showCloudConnected(data.user);
  3582. setStatus('Cloud connected via Google', 'green');
  3583. $('cloudPanel').style.display = 'block';
  3584. } else {
  3585. statusEl.style.color = 'var(--red)';
  3586. statusEl.textContent = data.error || 'Google login failed';
  3587. }
  3588. } catch (e) {
  3589. statusEl.style.color = 'var(--red)';
  3590. statusEl.textContent = 'Error: ' + e.message;
  3591. }
  3592. }
  3593. function googleLoginViaBrowser() {
  3594. window.open('https://www.visuallogic.ai', '_blank');
  3595. switchLoginTab('token');
  3596. }
  3597. // --- Token Login ---
  3598. async function doTokenLogin() {
  3599. const token = $('cloudDirectCookie').value.trim();
  3600. if (!token) { showLoginError('Paste the ih5bearer token value'); return; }
  3601. try {
  3602. await fetch('/api/settings', {
  3603. method: 'POST',
  3604. headers: { 'Content-Type': 'application/json' },
  3605. body: JSON.stringify({ cookie: token }),
  3606. });
  3607. // Verify
  3608. const status = await api('/api/cloud/status?refresh=true');
  3609. if (status.connected) {
  3610. closeCloudLogin();
  3611. showCloudConnected(status.user);
  3612. setStatus('Cloud connected via token', 'green');
  3613. $('cloudPanel').style.display = 'block';
  3614. } else {
  3615. showLoginError('Token invalid or expired');
  3616. }
  3617. } catch (e) {
  3618. showLoginError(e.message);
  3619. }
  3620. }
  3621. async function cloudLogout() {
  3622. await fetch('/api/cloud/logout', { method: 'POST' });
  3623. showCloudDisconnected();
  3624. setStatus('Cloud disconnected', 'yellow');
  3625. }
  3626. function showCloudSyncStatus(text, type) {
  3627. const el = $('cloudSyncStatus');
  3628. el.style.display = 'block';
  3629. el.className = 'cloud-status ' + (type || '');
  3630. el.textContent = text;
  3631. if (type === 'ok') setTimeout(() => { el.style.display = 'none'; }, 5000);
  3632. }
  3633. async function cloudSyncPush() {
  3634. let gid = $('cloudGid').value.trim();
  3635. // Auto-load GID from project profile if not set in UI
  3636. if (!gid) {
  3637. try {
  3638. const profile = normalizeProjectProfile(await api('/api/profile'));
  3639. gid = getProfileGid(profile);
  3640. if (gid) $('cloudGid').value = gid;
  3641. } catch {}
  3642. }
  3643. if (!gid) {
  3644. setStatus('No GID found — compile first to get one', 'yellow');
  3645. addMsg('assistant', 'No workspace GID. Running compile to create one...');
  3646. await compileProject();
  3647. gid = $('cloudGid').value.trim();
  3648. if (!gid) return;
  3649. }
  3650. showCloudSyncStatus('Pushing files to cloud...', 'syncing');
  3651. try {
  3652. const res = await fetch('/api/cloud/sync/push', {
  3653. method: 'POST',
  3654. headers: { 'Content-Type': 'application/json' },
  3655. body: JSON.stringify({ gid }),
  3656. });
  3657. const data = await res.json();
  3658. if (data.error) {
  3659. showCloudSyncStatus('Push failed: ' + data.error, 'error');
  3660. } else {
  3661. showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
  3662. addMsg('assistant', `**Cloud Push:** ${data.total} files synced to workspace GID:${gid}`);
  3663. }
  3664. } catch (e) {
  3665. showCloudSyncStatus('Push error: ' + e.message, 'error');
  3666. }
  3667. }
  3668. async function cloudSyncPull() {
  3669. const gid = $('cloudGid').value.trim();
  3670. if (!gid) {
  3671. setStatus('Set a Workspace GID first', 'red');
  3672. return;
  3673. }
  3674. showCloudSyncStatus('Pulling files from cloud...', 'syncing');
  3675. try {
  3676. const res = await fetch('/api/cloud/sync/pull', {
  3677. method: 'POST',
  3678. headers: { 'Content-Type': 'application/json' },
  3679. body: JSON.stringify({ gid }),
  3680. });
  3681. const data = await res.json();
  3682. if (data.error) {
  3683. showCloudSyncStatus('Pull failed: ' + data.error, 'error');
  3684. } else {
  3685. showCloudSyncStatus(`Pulled ${data.filesPulled} files`, 'ok');
  3686. addMsg('assistant', `**Cloud Pull:** ${data.filesPulled} files pulled from workspace GID:${gid}`);
  3687. await loadFileTree();
  3688. }
  3689. } catch (e) {
  3690. showCloudSyncStatus('Pull error: ' + e.message, 'error');
  3691. }
  3692. }
  3693. async function cloudCompile() {
  3694. const gid = $('cloudGid').value.trim();
  3695. const btn = $('compileBtn');
  3696. btn.disabled = true;
  3697. btn.innerHTML = '&#9203; Compiling...';
  3698. btn.style.opacity = '0.6';
  3699. setStatus('Cloud compile: syncing + compiling...', 'yellow');
  3700. showCloudSyncStatus('Sync + Compile in progress...', 'syncing');
  3701. try {
  3702. const body = {};
  3703. if (gid) body.gid = gid;
  3704. const res = await fetch('/api/cloud/compile', {
  3705. method: 'POST',
  3706. headers: { 'Content-Type': 'application/json' },
  3707. body: JSON.stringify(body),
  3708. });
  3709. const data = await res.json();
  3710. if (data.error) {
  3711. setStatus('Cloud compile failed: ' + data.error, 'red');
  3712. showCloudSyncStatus('Compile failed', 'error');
  3713. addMsg('assistant', 'Cloud compile failed: ' + data.error);
  3714. return;
  3715. }
  3716. // Update GID field with the result
  3717. if (data.gid) $('cloudGid').value = data.gid;
  3718. const urls = data.previewUrls || {};
  3719. const keys = Object.keys(urls);
  3720. const errList = data.errList || [];
  3721. const errCount = errList.length;
  3722. if (keys.length > 0) {
  3723. activatePreview(urls);
  3724. const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
  3725. addMsg('assistant', `**Cloud Compile ${errCount > 0 ? 'with ' + errCount + ' error(s)' : 'success'}** (GID: ${data.gid}, synced: ${data.syncedFiles} files)\n\n**Preview URLs:**\n${urlList}`);
  3726. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile — preview ready', errCount > 0 ? 'yellow' : 'green');
  3727. } else {
  3728. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile done', errCount > 0 ? 'yellow' : 'green');
  3729. }
  3730. showCloudSyncStatus(`Compiled (${data.syncedFiles} files synced)`, 'ok');
  3731. if (errCount > 0) {
  3732. const errLines = errList.map((e, i) => {
  3733. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  3734. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  3735. return ` ${i + 1}. ${JSON.stringify(e)}`;
  3736. }).join('\n');
  3737. addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
  3738. addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
  3739. }
  3740. // Completion summary
  3741. addMsg('assistant', errCount > 0
  3742. ? `Compile done, ${errCount} error(s). Check the error list and fix them.`
  3743. : `Compile done, no errors.`);
  3744. } catch (e) {
  3745. setStatus('Cloud compile error', 'red');
  3746. showCloudSyncStatus('Error: ' + e.message, 'error');
  3747. addMsg('assistant', 'Cloud compile error: ' + e.message);
  3748. } finally {
  3749. btn.disabled = false;
  3750. btn.innerHTML = '&#9654; Compile';
  3751. btn.style.opacity = '1';
  3752. }
  3753. }
  3754. // Check cloud status on load
  3755. async function initCloudStatus() {
  3756. try {
  3757. const data = await api('/api/cloud/status');
  3758. if (data.connected) {
  3759. $('cloudPanel').style.display = 'block';
  3760. showCloudConnected(data.user);
  3761. }
  3762. } catch {}
  3763. // Init backend message count for context exclusion
  3764. try {
  3765. const ctx = await api('/api/context/messages', activeConvId);
  3766. _lastBackendMsgCount = ctx.count || 0;
  3767. } catch {}
  3768. }
  3769. /** Check cloud login status on startup — validate cookie, show offline if expired */
  3770. async function checkCloudLoginStatus() {
  3771. try {
  3772. const data = await api('/api/cloud/status?refresh=true');
  3773. if (data.connected) {
  3774. $('cloudPanel').style.display = 'block';
  3775. showCloudConnected(data.user);
  3776. } else {
  3777. showCloudDisconnected();
  3778. if (data.error === 'Session expired') {
  3779. addMsg('assistant', 'Cloud session expired. Please log in again to enable compile & push.');
  3780. }
  3781. }
  3782. } catch {
  3783. showCloudDisconnected();
  3784. }
  3785. // Init backend message count
  3786. try {
  3787. const ctx = await api('/api/context/messages', activeConvId);
  3788. _lastBackendMsgCount = ctx.count || 0;
  3789. } catch {}
  3790. }
  3791. // ===================== FILE TREE =====================
  3792. function formatFileSize(bytes) {
  3793. if (bytes == null) return '';
  3794. if (bytes < 1024) return bytes + ' B';
  3795. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  3796. return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  3797. }
  3798. let _fileSizeMap = {}; // path → size in bytes
  3799. const FILE_TREE_CATEGORY_ORDER = ['Apps', 'Sections', 'ExtComponents', 'Services', 'Database', 'Theme', 'Process', 'Config', 'root', '.vl-code'];
  3800. 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 };
  3801. function getFileTreeCategoryRank(cat) {
  3802. const top = cat === 'root' ? 'root' : cat.split('/')[0];
  3803. const idx = FILE_TREE_CATEGORY_ORDER.indexOf(top);
  3804. const base = idx === -1 ? 90 : idx * 10;
  3805. return base + (cat === top ? 0 : 1);
  3806. }
  3807. function shouldHideFileTreePath(filePath, type) {
  3808. if (!showInternalFiles && filePath.startsWith('.vl-code/sessions/')) return true;
  3809. if (!showInternalFiles && filePath.startsWith('.vl-code/workflows/')) return true;
  3810. if (!showInternalFiles && filePath === '.vl-code/workspace.json') return true;
  3811. if (!showInternalFiles && filePath === '.vl-code/last-compile.json') return true;
  3812. if (/^manual_\d+\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) return true;
  3813. if (type === 'image' && /(?:^|\/)(manual_|screenshot_|screen_)/i.test(filePath)) return true;
  3814. return false;
  3815. }
  3816. function compareFileTreeItems(a, b) {
  3817. const typeDiff = (FILE_TREE_TYPE_ORDER[a.type] ?? 50) - (FILE_TREE_TYPE_ORDER[b.type] ?? 50);
  3818. if (typeDiff !== 0) return typeDiff;
  3819. return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
  3820. }
  3821. async function loadFileTree() {
  3822. try {
  3823. const data = await api('/api/files');
  3824. const tree = $('fileTree');
  3825. if (!currentWorkDir) {
  3826. 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>';
  3827. return;
  3828. }
  3829. tree.innerHTML = '';
  3830. // Rebuild the visible tree client-side so internal artifacts stay out of the way.
  3831. _fileSizeMap = {};
  3832. const grouped = new Map();
  3833. let hiddenCount = 0;
  3834. for (const file of data.files) {
  3835. if (file.size != null) _fileSizeMap[file.path] = file.size;
  3836. const parts = file.path.split('/');
  3837. const cat = parts.length > 1 ? parts.slice(0, -1).join('/') : 'root';
  3838. const name = file.name || parts[parts.length - 1];
  3839. const type = getType(name, cat);
  3840. if (shouldHideFileTreePath(file.path, type)) {
  3841. hiddenCount++;
  3842. continue;
  3843. }
  3844. if (!grouped.has(cat)) grouped.set(cat, []);
  3845. grouped.get(cat).push({ name, path: file.path, type });
  3846. }
  3847. const sorted = [...grouped.entries()].sort((a, b) => {
  3848. const rankDiff = getFileTreeCategoryRank(a[0]) - getFileTreeCategoryRank(b[0]);
  3849. if (rankDiff !== 0) return rankDiff;
  3850. return a[0].localeCompare(b[0], undefined, { numeric: true, sensitivity: 'base' });
  3851. });
  3852. let visibleCount = 0;
  3853. for (const [cat, files] of sorted) {
  3854. files.sort(compareFileTreeItems);
  3855. if (files.length === 0) continue;
  3856. const div = document.createElement('div');
  3857. div.className = 'category';
  3858. div.innerHTML = `<div class="cat-name">${cat === 'root' ? './' : cat + '/'}</div>`;
  3859. for (const file of files) {
  3860. visibleCount++;
  3861. const el = document.createElement('div');
  3862. el.className = 'file';
  3863. el.style.display = 'flex';
  3864. el.style.alignItems = 'center';
  3865. const fp = file.path;
  3866. el.dataset.path = fp;
  3867. 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>` : '';
  3868. el.innerHTML = `${getFileIcon(file.type, file.name)}<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${file.name}</span>${sizeStr}`;
  3869. el.onclick = () => openFileOrPreview(fp, file.type);
  3870. el.oncontextmenu = (e) => showFileCtxMenu(e, fp);
  3871. div.appendChild(el);
  3872. }
  3873. tree.appendChild(div);
  3874. }
  3875. $('fileCount').textContent = hiddenCount > 0
  3876. ? `${visibleCount} shown / ${data.files.length} total`
  3877. : `${visibleCount} files`;
  3878. } catch {}
  3879. }
  3880. function getType(name, cat) {
  3881. const ext = name.split('.').pop().toLowerCase();
  3882. const base = name.toLowerCase();
  3883. // VL source types
  3884. const vlType = {vx:'app',sc:'section',cp:'component',vs:'service',vdb:'database',vth:'theme'}[ext];
  3885. if (vlType) return vlType;
  3886. // Image files
  3887. if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return 'image';
  3888. // Reports
  3889. if (base.includes('report') || base.includes('result')) return 'report';
  3890. // Workflows
  3891. if ((cat && cat.includes('workflow')) || base.includes('workflow')) return 'workflow';
  3892. // Log files
  3893. if (ext === 'log' || base.includes('log')) return 'log';
  3894. // Process artifacts
  3895. if ((cat && cat.startsWith('Process')) || base.includes('process')) return 'process';
  3896. // Config files
  3897. if (base === 'conventions.json' || base === 'project.json' || base.includes('config')) return 'config';
  3898. // Standard types
  3899. return {json:'json',md:'doc',txt:'doc',html:'doc',csv:'doc'}[ext] || 'doc';
  3900. }
  3901. /** Get VS Code-style icon for file type */
  3902. function getFileIcon(type, name) {
  3903. const icons = {
  3904. app: '<span class="file-icon" style="color:var(--accent)">&#9670;</span>', // diamond
  3905. section: '<span class="file-icon" style="color:var(--green)">&#9638;</span>', // square
  3906. component: '<span class="file-icon" style="color:var(--yellow)">&#9672;</span>', // nested diamond
  3907. service: '<span class="file-icon" style="color:var(--red)">&#9881;</span>', // gear
  3908. database: '<span class="file-icon" style="color:var(--text2)">&#9707;</span>', // cylinder-like
  3909. theme: '<span class="file-icon" style="color:var(--purple)">&#9733;</span>', // star
  3910. json: '<span class="file-icon" style="color:#e0ad40">{ }</span>',
  3911. doc: '<span class="file-icon" style="color:#9da5ae">&#9776;</span>', // hamburger/lines
  3912. image: '<span class="file-icon" style="color:#f0883e">&#9634;</span>', // frame
  3913. report: '<span class="file-icon" style="color:#3fb950">&#9745;</span>', // ballot check
  3914. log: '<span class="file-icon" style="color:#8b949e">&#9683;</span>', // circle half
  3915. config: '<span class="file-icon" style="color:#d29922">&#9881;</span>', // gear
  3916. workflow: '<span class="file-icon" style="color:#a371f7">&#9654;</span>', // play
  3917. process: '<span class="file-icon" style="color:#c49bff">&#9998;</span>', // pencil
  3918. };
  3919. return icons[type] || icons.doc;
  3920. }
  3921. // ===================== EDITOR =====================
  3922. async function openFile(fpath) {
  3923. try {
  3924. const data = await api(`/api/file?path=${encodeURIComponent(fpath)}`);
  3925. const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
  3926. // Clear all previous file tabs (no caching) — keep only special tabs
  3927. for (const [k, v] of openFiles) {
  3928. if (v.type === 'file') openFiles.delete(k);
  3929. }
  3930. openFiles.set(fpath, { type: 'file', content });
  3931. currentFile = fpath;
  3932. // Switch to code mode if in meta/flow mode
  3933. if (currentMode !== 'code') switchMode('code');
  3934. renderTabs();
  3935. showTabContent(fpath);
  3936. document.querySelectorAll('.file').forEach(el => el.classList.toggle('active', el.dataset.path === fpath));
  3937. $('currentFile').textContent = fpath;
  3938. } catch (err) {
  3939. console.error('openFile failed:', fpath, err);
  3940. setStatus('Failed to open ' + fpath.split('/').pop(), 'red');
  3941. }
  3942. }
  3943. function openFileOrPreview(fpath, type) {
  3944. if (type === 'image') {
  3945. showImagePreview(fpath);
  3946. } else {
  3947. openFile(fpath);
  3948. }
  3949. }
  3950. function showImagePreview(fpath) {
  3951. let overlay = $('imagePreviewOverlay');
  3952. if (!overlay) {
  3953. overlay = document.createElement('div');
  3954. overlay.id = 'imagePreviewOverlay';
  3955. 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;';
  3956. overlay.onclick = () => overlay.style.display = 'none';
  3957. overlay.innerHTML = `
  3958. <div id="imgPreviewTitle" style="color:#fff;font-size:13px;margin-bottom:8px;"></div>
  3959. <img id="imgPreviewImg" style="max-width:90vw;max-height:80vh;border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,.5);">
  3960. <div id="imgPreviewInfo" style="color:var(--text2);font-size:11px;margin-top:8px;"></div>
  3961. `;
  3962. document.body.appendChild(overlay);
  3963. }
  3964. const url = `/api/file/raw?path=${encodeURIComponent(fpath)}`;
  3965. $('imgPreviewImg').src = url;
  3966. $('imgPreviewTitle').textContent = fpath;
  3967. const size = _fileSizeMap[fpath];
  3968. $('imgPreviewInfo').textContent = size != null ? formatFileSize(size) : '';
  3969. overlay.style.display = 'flex';
  3970. }
  3971. /** Open a special (non-file) tab: workflow DAG or metadata graph */
  3972. function openSpecialTab(key, type, title, data) {
  3973. openFiles.set(key, { type, title, data });
  3974. currentFile = key;
  3975. renderTabs();
  3976. showTabContent(key);
  3977. $('currentFile').textContent = title;
  3978. }
  3979. /** Close a tab by key */
  3980. function closeTab(key, evt) {
  3981. if (evt) evt.stopPropagation();
  3982. openFiles.delete(key);
  3983. // Clean up iframe if it was a special tab
  3984. const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
  3985. if (iframe) iframe.remove();
  3986. // Switch to another tab or show placeholder
  3987. if (currentFile === key) {
  3988. const keys = [...openFiles.keys()];
  3989. if (keys.length > 0) {
  3990. currentFile = keys[keys.length - 1];
  3991. renderTabs();
  3992. showTabContent(currentFile);
  3993. $('currentFile').textContent = currentFile;
  3994. } else {
  3995. currentFile = null;
  3996. renderTabs();
  3997. $('cmEditorWrap').style.display = 'none';
  3998. $('editor').style.display = 'none';
  3999. $('iframeContainer').style.display = 'none';
  4000. $('editorPlaceholder').style.display = 'block';
  4001. $('currentFile').textContent = '';
  4002. }
  4003. } else {
  4004. renderTabs();
  4005. }
  4006. }
  4007. function renderTabs() {
  4008. const tabs = $('editorTabs');
  4009. tabs.innerHTML = '';
  4010. // Only show tab bar if there are special (non-file) tabs alongside the current file
  4011. const hasSpecialTabs = [...openFiles.values()].some(v => v.type !== 'file');
  4012. tabs.style.display = (openFiles.size > 1 || hasSpecialTabs) ? 'flex' : 'none';
  4013. for (const [key, info] of openFiles) {
  4014. const tab = document.createElement('div');
  4015. tab.className = 'tab' + (key === currentFile ? ' active' : '');
  4016. const icons = { file: '', workflow: '\u2B21 ', metadata: '\u25C9 ' };
  4017. const label = info.type === 'file' ? key.split('/').pop() : (info.title || key);
  4018. tab.innerHTML = `<span class="tab-icon">${icons[info.type] || ''}</span><span>${escapeHtml(label)}</span><span class="tab-close" onclick="closeTab('${key.replace(/'/g, "\\'")}', event)">&times;</span>`;
  4019. tab.onclick = () => { currentFile = key; renderTabs(); showTabContent(key); };
  4020. tabs.appendChild(tab);
  4021. }
  4022. }
  4023. /** Show content for the active tab (file editor or iframe) */
  4024. function showTabContent(key) {
  4025. const info = openFiles.get(key);
  4026. if (!info) return;
  4027. $('editorPlaceholder').style.display = 'none';
  4028. $('codePreview').style.display = 'none';
  4029. $('mdPreview').style.display = 'none';
  4030. if (info.type === 'file') {
  4031. $('iframeContainer').style.display = 'none';
  4032. // Try CodeMirror, fall back to textarea
  4033. initCodeMirror();
  4034. if (cmEditor) {
  4035. $('editor').style.display = 'none';
  4036. $('cmEditorWrap').style.display = 'block';
  4037. cmEditor.setValue(info.content || '');
  4038. cmEditor.setOption('mode', getCmMode(key));
  4039. cmEditor.clearHistory();
  4040. setTimeout(() => cmEditor.refresh(), 10);
  4041. } else {
  4042. // Textarea fallback
  4043. $('cmEditorWrap').style.display = 'none';
  4044. $('editor').style.display = 'block';
  4045. $('editor').value = info.content || '';
  4046. }
  4047. $('currentFile').textContent = key;
  4048. } else {
  4049. // Show iframe, hide code editor
  4050. $('editor').style.display = 'none';
  4051. $('cmEditorWrap').style.display = 'none';
  4052. $('iframeContainer').style.display = 'block';
  4053. // Hide all iframes, show the one for this tab
  4054. const container = $('iframeContainer');
  4055. [...container.children].forEach(f => f.style.display = 'none');
  4056. let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
  4057. if (!iframe) {
  4058. iframe = document.createElement('iframe');
  4059. iframe.dataset.tab = key;
  4060. iframe.sandbox = 'allow-scripts allow-same-origin';
  4061. if (info.type === 'workflow') iframe.src = '/workflow-editor.html';
  4062. else if (info.type === 'metadata') iframe.src = '/metadata-viewer.html';
  4063. iframe.onload = () => {
  4064. // Send data to iframe once ready
  4065. if (info.data) {
  4066. const msg = info.type === 'workflow'
  4067. ? { type: 'loadWorkflow', data: info.data, workflowName: info.workflowName || info.name || null }
  4068. : { type: 'loadMetadata', data: info.data };
  4069. iframe.contentWindow.postMessage(msg, '*');
  4070. }
  4071. };
  4072. container.appendChild(iframe);
  4073. }
  4074. iframe.style.display = 'block';
  4075. }
  4076. }
  4077. // Legacy alias
  4078. function showEditor(content) {
  4079. initCodeMirror();
  4080. $('iframeContainer').style.display = 'none';
  4081. $('editorPlaceholder').style.display = 'none';
  4082. if (cmEditor) {
  4083. $('cmEditorWrap').style.display = 'block';
  4084. $('editor').style.display = 'none';
  4085. cmEditor.setValue(content || '');
  4086. setTimeout(() => cmEditor.refresh(), 10);
  4087. } else {
  4088. $('cmEditorWrap').style.display = 'none';
  4089. $('editor').style.display = 'block';
  4090. $('editor').value = content || '';
  4091. }
  4092. }
  4093. $('editor').addEventListener('keydown', e => {
  4094. if ((e.metaKey || e.ctrlKey) && e.key === 's') {
  4095. e.preventDefault();
  4096. saveCurrentFile();
  4097. }
  4098. });
  4099. async function saveCurrentFile() {
  4100. if (!currentFile) return;
  4101. const info = openFiles.get(currentFile);
  4102. if (!info || info.type !== 'file') return; // Only save file tabs
  4103. const content = cmEditor ? cmEditor.getValue() : $('editor').value;
  4104. info.content = content;
  4105. await fetch('/api/file', { method:'POST', headers:{'Content-Type':'application/json'},
  4106. body: JSON.stringify({ path: currentFile, content }) });
  4107. setStatus('Saved ' + currentFile.split('/').pop(), 'green');
  4108. }
  4109. // ===================== DETAIL PANEL =====================
  4110. let _detailEntryCount = 0;
  4111. let _detailManualClosed = false; // When user manually closes, prevent auto-open
  4112. function toggleDetailPanel() {
  4113. const panel = $('detailPanel');
  4114. const wasOpen = panel.classList.contains('open');
  4115. panel.classList.toggle('open');
  4116. // If user is closing it manually, set flag to prevent auto-open
  4117. if (wasOpen) {
  4118. _detailManualClosed = true;
  4119. } else {
  4120. _detailManualClosed = false;
  4121. }
  4122. }
  4123. /** Cross-panel navigation: chat → detail */
  4124. function scrollToDetailEntry(linkId) {
  4125. const panel = $('detailPanel');
  4126. // Open detail panel (even if manually closed — user explicitly clicked)
  4127. if (!panel.classList.contains('open')) {
  4128. panel.classList.add('open');
  4129. _detailManualClosed = false;
  4130. }
  4131. const detailEl = panel.querySelector(`.detail-entry[data-link-id="${linkId}"]`);
  4132. if (detailEl) {
  4133. detailEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
  4134. detailEl.style.outline = '2px solid var(--accent)';
  4135. detailEl.style.background = 'rgba(139,233,253,0.08)';
  4136. setTimeout(() => { detailEl.style.outline = ''; detailEl.style.background = ''; }, 2000);
  4137. }
  4138. }
  4139. /** Cross-panel navigation: detail → chat */
  4140. function scrollToChatEntry(linkId) {
  4141. const chatEl = document.querySelector(`.tool-group[data-link-id="${linkId}"]`);
  4142. if (chatEl) {
  4143. chatEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
  4144. chatEl.style.outline = '2px solid var(--accent)';
  4145. chatEl.style.background = 'rgba(139,233,253,0.08)';
  4146. setTimeout(() => { chatEl.style.outline = ''; chatEl.style.background = ''; }, 2000);
  4147. }
  4148. }
  4149. function clearDetailPanel() {
  4150. $('detailBody').innerHTML = '';
  4151. _detailEntryCount = 0;
  4152. $('detailCount').textContent = '';
  4153. // Also clear backend compile cache so stale data doesn't reappear
  4154. fetch('/api/detail-log', { method: 'DELETE' }).catch(() => {});
  4155. }
  4156. /**
  4157. * Add entry to detail panel.
  4158. * @param {string} phase - Category tag (e.g., 'generate', 'workflow', 'tool')
  4159. * @param {string} message - Main message text
  4160. * @param {*} data - Optional JSON data to display
  4161. * @param {string} type - 'info'|'success'|'error'|'warn'
  4162. * @param {Object} opts - Optional: { depth:0-3, agentId, agentName, parentContainer }
  4163. */
  4164. let _detailLinkId = 0;
  4165. function addDetailEntry(phase, message, data, type = 'info', opts = {}) {
  4166. const panel = $('detailPanel');
  4167. // Only auto-open if user hasn't manually closed it
  4168. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4169. _detailEntryCount++;
  4170. $('detailCount').textContent = `${_detailEntryCount} entries`;
  4171. const body = opts.parentContainer || $('detailBody');
  4172. const now = new Date().toLocaleTimeString();
  4173. const div = document.createElement('div');
  4174. const depth = opts.depth || 0;
  4175. div.className = `detail-entry ${type}` + (depth > 0 ? ` depth-${Math.min(depth, 3)}` : '');
  4176. // Cross-panel linkage: assign ID so chat can scroll to this entry
  4177. if (opts.linkId) {
  4178. div.dataset.linkId = opts.linkId;
  4179. div.style.cursor = 'pointer';
  4180. div.title = 'Click to scroll to chat';
  4181. div.onclick = () => scrollToChatEntry(opts.linkId);
  4182. }
  4183. let html = `<span class="de-time">${now}</span>`;
  4184. if (opts.agentName) html += `<span class="de-agent">[${escapeHtml(opts.agentName)}]</span>`;
  4185. if (phase) html += `<span class="de-phase" data-phase="${escapeHtml(phase)}">[${escapeHtml(phase)}]</span>`;
  4186. html += `<div class="de-msg">${escapeHtml(message)}</div>`;
  4187. if (data) {
  4188. const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  4189. const isLong = dataStr.length > 200;
  4190. html += `<div class="de-data${isLong ? ' collapsed' : ''}" onclick="event.stopPropagation();this.classList.toggle('collapsed')">${escapeHtml(dataStr)}</div>`;
  4191. }
  4192. div.innerHTML = html;
  4193. body.appendChild(div);
  4194. body.scrollTop = body.scrollHeight;
  4195. return div;
  4196. }
  4197. /**
  4198. * Create or get an agent group in the detail panel for hierarchical display.
  4199. * Returns a container element where child entries can be appended.
  4200. */
  4201. const _detailAgentGroups = {};
  4202. function getOrCreateAgentGroup(agentId, agentName, parentContainer) {
  4203. if (_detailAgentGroups[agentId]) return _detailAgentGroups[agentId];
  4204. const body = parentContainer || $('detailBody');
  4205. const group = document.createElement('div');
  4206. group.className = 'detail-agent-group';
  4207. group.dataset.agentId = agentId;
  4208. group.innerHTML = `
  4209. <div class="detail-agent-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
  4210. <span class="dag-icon">&#9654;</span>
  4211. <span class="dag-name">${escapeHtml(agentName)}</span>
  4212. <span class="dag-desc"></span>
  4213. <span class="dag-status" style="color:var(--yellow);">running</span>
  4214. </div>
  4215. <div class="detail-agent-children"></div>`;
  4216. body.appendChild(group);
  4217. const children = group.querySelector('.detail-agent-children');
  4218. _detailAgentGroups[agentId] = children;
  4219. body.scrollTop = body.scrollHeight;
  4220. return children;
  4221. }
  4222. function completeAgentGroup(agentId, status) {
  4223. const container = _detailAgentGroups[agentId];
  4224. if (!container) return;
  4225. const group = container.closest('.detail-agent-group');
  4226. if (!group) return;
  4227. const statusEl = group.querySelector('.dag-status');
  4228. if (statusEl) {
  4229. statusEl.textContent = status || 'done';
  4230. statusEl.style.color = status === 'error' ? 'var(--red)' : 'var(--green)';
  4231. }
  4232. const icon = group.querySelector('.dag-icon');
  4233. if (icon) icon.textContent = status === 'error' ? '✗' : '✓';
  4234. }
  4235. // Stream box: accumulates streaming content (LLM tokens) in one expandable container
  4236. const _streamBoxes = {};
  4237. function appendToStreamBox(boxId, label, text) {
  4238. const body = $('detailBody');
  4239. const panel = $('detailPanel');
  4240. // Only auto-open if user hasn't manually closed it
  4241. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4242. let box = _streamBoxes[boxId];
  4243. if (!box) {
  4244. const div = document.createElement('div');
  4245. const isThinking = boxId.includes('thinking');
  4246. div.className = 'detail-entry stream-box' + (isThinking ? ' thinking-stream' : '');
  4247. div.innerHTML = `<div class="de-stream-header" onclick="this.parentElement.classList.toggle('collapsed')">
  4248. <span class="de-stream-label">${escapeHtml(label)}</span>
  4249. <span class="de-stream-size">0 chars</span>
  4250. <span class="de-stream-toggle">▼</span>
  4251. </div><div class="de-stream-content"></div>`;
  4252. body.appendChild(div);
  4253. const contentEl = div.querySelector('.de-stream-content');
  4254. const sizeEl = div.querySelector('.de-stream-size');
  4255. box = { el: div, contentEl, sizeEl, charCount: 0 };
  4256. _streamBoxes[boxId] = box;
  4257. }
  4258. box.charCount += text.length;
  4259. // Batch DOM updates: buffer text and flush periodically for smooth rendering
  4260. if (!box._buffer) box._buffer = '';
  4261. box._buffer += text;
  4262. if (!box._flushTimer) {
  4263. box._flushTimer = setTimeout(() => {
  4264. box.contentEl.textContent += box._buffer;
  4265. box._buffer = '';
  4266. box._flushTimer = null;
  4267. box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
  4268. body.scrollTop = body.scrollHeight;
  4269. }, 150); // flush every 150ms — smooth but not jittery
  4270. }
  4271. }
  4272. function flushStreamBoxes() {
  4273. for (const id in _streamBoxes) {
  4274. const box = _streamBoxes[id];
  4275. if (box._buffer) {
  4276. box.contentEl.textContent += box._buffer;
  4277. box._buffer = '';
  4278. if (box._flushTimer) { clearTimeout(box._flushTimer); box._flushTimer = null; }
  4279. box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
  4280. }
  4281. }
  4282. }
  4283. function clearStreamBoxes() {
  4284. flushStreamBoxes();
  4285. for (const id in _streamBoxes) delete _streamBoxes[id];
  4286. }
  4287. // ===================== WORKFLOW STEP CARDS (Detail Log) =====================
  4288. // Enhanced step cards for workflow execution — inputs, outputs, files, re-run
  4289. const _stepCards = {}; // stepID → { el, status, startTime, inputs, outputs, files, thinking, response }
  4290. let _lastWorkflowName = ''; // For rerun
  4291. let _lastRunCheckpoint = null;
  4292. function addStepCard(stepID, type, title, resolvedInputs) {
  4293. const body = $('detailBody');
  4294. const panel = $('detailPanel');
  4295. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4296. const card = document.createElement('div');
  4297. card.className = 'detail-step-card running';
  4298. card.dataset.stepId = stepID;
  4299. // Icon based on type
  4300. const typeIcons = { LLM: '🤖', Write: '📝', Set: '⚙️', Branch: '🔀', Loop: '🔁', Noop: '•', Service: '🔌', API: '🌐', Pause: '⏸', MetaDiff: '📊', ComponentFetch: '📦', ClearFiles: '🗑' };
  4301. const icon = typeIcons[type] || '▶';
  4302. card.innerHTML = `
  4303. <div class="dsc-header" onclick="toggleStepCardBody('${stepID}')" oncontextmenu="showStepCtxMenu(event, '${escapeHtml(stepID)}')">
  4304. <span class="dsc-icon">${icon}</span>
  4305. <span class="dsc-title">${escapeHtml(title || stepID)}</span>
  4306. <span class="dsc-type">${escapeHtml(type || '')}</span>
  4307. <span class="dsc-duration" id="dsc-dur-${stepID}"></span>
  4308. <span class="dsc-hover-actions">
  4309. <button class="dsc-hover-btn" title="Re-run from here" onclick="event.stopPropagation();openRerunDialog('${escapeHtml(stepID)}')">🔄</button>
  4310. <button class="dsc-hover-btn" title="Highlight in DAG" onclick="event.stopPropagation();highlightStepInDAG('${escapeHtml(stepID)}')">🔍</button>
  4311. <button class="dsc-hover-btn" title="Copy outputs" onclick="event.stopPropagation();copyStepOutputs('${escapeHtml(stepID)}')">📋</button>
  4312. </span>
  4313. </div>
  4314. <div class="dsc-body" id="dsc-body-${stepID}"></div>`;
  4315. body.appendChild(card);
  4316. body.scrollTop = body.scrollHeight;
  4317. const state = {
  4318. el: card,
  4319. bodyEl: card.querySelector('.dsc-body'),
  4320. status: 'running',
  4321. startTime: Date.now(),
  4322. type, title, stepID,
  4323. files: [],
  4324. };
  4325. _stepCards[stepID] = state;
  4326. // Add inputs section if available
  4327. if (resolvedInputs) {
  4328. addStepCardSection(stepID, 'Inputs', resolvedInputs);
  4329. }
  4330. _detailEntryCount++;
  4331. $('detailCount').textContent = `${_detailEntryCount} entries`;
  4332. return state;
  4333. }
  4334. function toggleStepCardBody(stepID) {
  4335. const state = _stepCards[stepID];
  4336. if (!state) return;
  4337. state.bodyEl.classList.toggle('open');
  4338. }
  4339. function addStepCardSection(stepID, label, data, collapsed = true) {
  4340. const state = _stepCards[stepID];
  4341. if (!state) return;
  4342. const sec = document.createElement('div');
  4343. sec.className = 'dsc-section';
  4344. const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  4345. const isLong = dataStr.length > 500;
  4346. const sectionId = `dsc-sec-${stepID}-${label.replace(/\s/g, '')}`;
  4347. sec.innerHTML = `
  4348. <div class="dsc-section-header" onclick="toggleStepCardSection('${sectionId}')">
  4349. <span class="dsc-arrow${collapsed ? '' : ' open'}" id="arr-${sectionId}">▶</span>
  4350. <span>${escapeHtml(label)}</span>
  4351. ${isLong ? '<span style="color:var(--text2);font-size:8px;">(' + (dataStr.length > 1024 ? (dataStr.length / 1024).toFixed(1) + 'KB' : dataStr.length + 'B') + ')</span>' : ''}
  4352. </div>
  4353. <div class="dsc-section-content${collapsed ? '' : ' open'}${isLong ? ' truncated' : ''}" id="${sectionId}">${escapeHtml(isLong ? dataStr.substring(0, 2000) : dataStr)}</div>`;
  4354. state.bodyEl.appendChild(sec);
  4355. }
  4356. function toggleStepCardSection(sectionId) {
  4357. const el = document.getElementById(sectionId);
  4358. const arr = document.getElementById('arr-' + sectionId);
  4359. if (el) el.classList.toggle('open');
  4360. if (arr) arr.classList.toggle('open');
  4361. }
  4362. function completeStepCard(stepID, outputs, selected, duration_ms) {
  4363. const state = _stepCards[stepID];
  4364. if (!state) return;
  4365. state.status = 'done';
  4366. state.el.className = 'detail-step-card done';
  4367. // Update icon
  4368. const iconEl = state.el.querySelector('.dsc-icon');
  4369. if (iconEl) iconEl.textContent = '✓';
  4370. // Duration
  4371. const dur = duration_ms || (Date.now() - state.startTime);
  4372. const durEl = state.el.querySelector('.dsc-duration');
  4373. if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
  4374. // Add outputs section
  4375. if (outputs) {
  4376. addStepCardSection(stepID, 'Outputs', outputs);
  4377. }
  4378. if (selected) {
  4379. addStepCardSection(stepID, 'Branch Selected', selected, false);
  4380. }
  4381. // Add files section if any files were written during this step
  4382. if (state.files.length > 0) {
  4383. const fileList = state.files.map(f => `📄 ${f}`).join('\n');
  4384. addStepCardSection(stepID, `Files (${state.files.length})`, fileList, false);
  4385. }
  4386. // Add re-run button
  4387. const actions = document.createElement('div');
  4388. actions.className = 'dsc-actions';
  4389. actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')">🔄 Re-run from here</button>`;
  4390. state.bodyEl.appendChild(actions);
  4391. // Open body to show results
  4392. state.bodyEl.classList.add('open');
  4393. }
  4394. function errorStepCard(stepID, error, duration_ms) {
  4395. const state = _stepCards[stepID];
  4396. if (!state) return;
  4397. state.status = 'error';
  4398. state.el.className = 'detail-step-card error';
  4399. const iconEl = state.el.querySelector('.dsc-icon');
  4400. if (iconEl) iconEl.textContent = '✗';
  4401. const dur = duration_ms || (Date.now() - state.startTime);
  4402. const durEl = state.el.querySelector('.dsc-duration');
  4403. if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
  4404. addStepCardSection(stepID, 'Error', error, false);
  4405. // Add re-run button even on error (especially useful here)
  4406. const actions = document.createElement('div');
  4407. actions.className = 'dsc-actions';
  4408. actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')" style="border-color:var(--red);color:var(--red);">🔄 Re-run from here</button>`;
  4409. state.bodyEl.appendChild(actions);
  4410. state.bodyEl.classList.add('open');
  4411. }
  4412. /** Track file writes to the current running step card */
  4413. function addFileToStepCard(stepID, filePath) {
  4414. const state = _stepCards[stepID];
  4415. if (!state) return;
  4416. state.files.push(filePath);
  4417. }
  4418. /** Get current running step ID (for file_done association) */
  4419. function getCurrentRunningStepID() {
  4420. for (const [id, s] of Object.entries(_stepCards)) {
  4421. if (s.status === 'running') return id;
  4422. }
  4423. return null;
  4424. }
  4425. // ===================== STEP CARD CONTEXT MENU =====================
  4426. let _stepCtxTarget = null; // stepID of the right-clicked card
  4427. function showStepCtxMenu(e, stepID) {
  4428. e.preventDefault();
  4429. e.stopPropagation();
  4430. _stepCtxTarget = stepID;
  4431. const menu = $('stepCtxMenu');
  4432. menu.style.left = e.clientX + 'px';
  4433. menu.style.top = e.clientY + 'px';
  4434. menu.classList.add('open');
  4435. }
  4436. function _closeStepCtxMenu() { $('stepCtxMenu').classList.remove('open'); }
  4437. document.addEventListener('click', _closeStepCtxMenu);
  4438. function stepCtxRerun() {
  4439. _closeStepCtxMenu();
  4440. if (_stepCtxTarget) openRerunDialog(_stepCtxTarget);
  4441. }
  4442. function stepCtxViewInDAG() {
  4443. _closeStepCtxMenu();
  4444. if (_stepCtxTarget) highlightStepInDAG(_stepCtxTarget);
  4445. }
  4446. function stepCtxCopyOutputs() {
  4447. _closeStepCtxMenu();
  4448. if (_stepCtxTarget) copyStepOutputs(_stepCtxTarget);
  4449. }
  4450. function stepCtxCopyFiles() {
  4451. _closeStepCtxMenu();
  4452. const state = _stepCards[_stepCtxTarget];
  4453. if (!state || !state.files.length) return;
  4454. navigator.clipboard.writeText(state.files.join('\n')).then(
  4455. () => setStatus(`Copied ${state.files.length} file path(s)`, 'green'),
  4456. () => setStatus('Copy failed', 'red')
  4457. );
  4458. }
  4459. function stepCtxToggleBody() {
  4460. _closeStepCtxMenu();
  4461. if (_stepCtxTarget) toggleStepCardBody(_stepCtxTarget);
  4462. }
  4463. function stepCtxExpandAll() {
  4464. _closeStepCtxMenu();
  4465. const state = _stepCards[_stepCtxTarget];
  4466. if (!state) return;
  4467. state.bodyEl.classList.add('open');
  4468. state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.add('open'));
  4469. state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.add('open'));
  4470. }
  4471. function stepCtxCollapseAll() {
  4472. _closeStepCtxMenu();
  4473. const state = _stepCards[_stepCtxTarget];
  4474. if (!state) return;
  4475. state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.remove('open'));
  4476. state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.remove('open'));
  4477. }
  4478. /** Highlight a step in the DAG visualization */
  4479. function highlightStepInDAG(stepID) {
  4480. if (currentMode !== 'flow') switchMode('flow');
  4481. sendToWorkflowIframe({ type: 'highlightNode', nodeId: stepID });
  4482. }
  4483. /** Copy step outputs to clipboard */
  4484. function copyStepOutputs(stepID) {
  4485. const state = _stepCards[stepID];
  4486. if (!state) return;
  4487. // Find outputs section content
  4488. const outputSec = state.bodyEl.querySelector(`#dsc-sec-${stepID}-Outputs`);
  4489. const text = outputSec ? outputSec.textContent : '(no outputs)';
  4490. navigator.clipboard.writeText(text).then(
  4491. () => setStatus('Outputs copied to clipboard', 'green'),
  4492. () => setStatus('Copy failed', 'red')
  4493. );
  4494. }
  4495. // Debug panel removed — all debug info goes to Detail Log
  4496. function debugLog() {} // no-op stub for any remaining calls
  4497. function closeChatMoreMenu() {
  4498. $('chatMoreMenu')?.classList.remove('open');
  4499. }
  4500. function toggleChatMoreMenu(e) {
  4501. e?.stopPropagation();
  4502. const menu = $('chatMoreMenu');
  4503. if (!menu) return;
  4504. menu.classList.toggle('open');
  4505. }
  4506. function chatMenuAction(action) {
  4507. closeChatMoreMenu();
  4508. if (action === 'blueprint') return sendSkillCmd('blueprint');
  4509. if (action === 'search') return openChatSearch();
  4510. if (action === 'compact') return toggleCompactMode();
  4511. if (action === 'settings') return openSettings();
  4512. }
  4513. function toggleCompactMode() {
  4514. const panel = $('chatPanel');
  4515. const isCompact = panel.classList.toggle('compact');
  4516. const menuItem = $('compactMenuItem');
  4517. if (menuItem) menuItem.textContent = isCompact ? 'Full Mode' : 'Compact Mode';
  4518. }
  4519. // ===================== AUTO-SCREENSHOTS =====================
  4520. let _contextScreenshots = []; // Auto-attached to next LLM message
  4521. /** Append screenshot thumbnails to a chat message element */
  4522. function appendScreenshotToChat(msgEl, url, name) {
  4523. if (!msgEl) return;
  4524. let container = msgEl.querySelector('.msg-screenshots');
  4525. if (!container) {
  4526. container = document.createElement('div');
  4527. container.className = 'msg-screenshots';
  4528. msgEl.appendChild(container);
  4529. }
  4530. const item = document.createElement('div');
  4531. item.className = 'ss-item';
  4532. const img = document.createElement('img');
  4533. img.src = url;
  4534. img.onclick = () => window.open(url);
  4535. img.title = name;
  4536. const label = document.createElement('span');
  4537. label.className = 'ss-label';
  4538. label.textContent = name.replace(/^step_/, 'Step ').replace(/_\d+$/, '');
  4539. item.appendChild(img);
  4540. item.appendChild(label);
  4541. container.appendChild(item);
  4542. scrollChat();
  4543. }
  4544. /** Convert blob to base64 data string */
  4545. function blobToBase64(blob) {
  4546. return new Promise((resolve) => {
  4547. const reader = new FileReader();
  4548. reader.onload = () => resolve(reader.result.split(',')[1]);
  4549. reader.readAsDataURL(blob);
  4550. });
  4551. }
  4552. // ===================== CHAT =====================
  4553. let _currentAbortController = null;
  4554. let _chatStartTime = 0;
  4555. let _chatElapsedTimer = null;
  4556. async function stopExecution() {
  4557. // 1. Abort the frontend fetch
  4558. if (_currentAbortController) { _currentAbortController.abort(); _currentAbortController = null; }
  4559. // 2. Tell the server to abort this chat session
  4560. try { await fetch('/api/abort', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) }); } catch {}
  4561. // 3. Finalize all spinning indicators
  4562. finalizeAllToolSpinners();
  4563. clearSpinnerSafetyTimeout();
  4564. // 4. UI cleanup
  4565. $('chatStop').style.display = 'none';
  4566. $('chatSend').style.display = '';
  4567. $('chatSend').disabled = false;
  4568. setChatStatusRunning(false);
  4569. setStatus('Stopped', 'red');
  4570. setTimeout(() => setStatus('Ready', 'green'), 2000);
  4571. }
  4572. function setChatStatusRunning(running) {
  4573. const statusBar = $('chatStatusBar');
  4574. if (running) {
  4575. statusBar.style.display = 'flex';
  4576. _chatStartTime = Date.now();
  4577. updateChatStatusBar('Thinking...', '');
  4578. _chatElapsedTimer = setInterval(updateChatElapsed, 1000);
  4579. setTabStatus('busy');
  4580. } else {
  4581. statusBar.style.display = 'none';
  4582. if (_chatElapsedTimer) { clearInterval(_chatElapsedTimer); _chatElapsedTimer = null; }
  4583. // If tab is not focused, show "new output" indicator instead of idle
  4584. if (!_tabHasFocus) {
  4585. setTabStatus('newOutput');
  4586. } else {
  4587. setTabStatus('idle');
  4588. }
  4589. }
  4590. }
  4591. const _toolVerbs = {
  4592. ReadFile:'Reading', WriteFile:'Writing', EditFile:'Editing', Glob:'Searching files',
  4593. Grep:'Searching code', VLCompile:'Compiling', VLParse:'Compiling', VLValidate:'Validating',
  4594. VLMetadata:'Analyzing metadata', VLSymbols:'Indexing symbols', VLImpact:'Analyzing impact',
  4595. VLAutoFix:'Auto-fixing', VLSyntaxRef:'Looking up syntax', VLCascadeEdit:'Cascade editing',
  4596. SubAgent:'Running agent', AutoTestPipeline:'Running tests', TodoWrite:'Planning',
  4597. AskUserQuestion:'Waiting for input', MetaDiff:'Diffing metadata', SectionDiff:'Diffing sections',
  4598. BrowserNavigate:'Navigating', BrowserClick:'Clicking', BrowserType:'Typing', BrowserScreenshot:'Taking screenshot',
  4599. };
  4600. function updateChatStatusBar(phase, detail) {
  4601. $('csPhase').textContent = phase || '';
  4602. $('csDetail').textContent = detail || '';
  4603. updateChatElapsed();
  4604. }
  4605. function toolToVerb(toolName, input) {
  4606. const verb = _toolVerbs[toolName] || (toolName + '...');
  4607. if (input && typeof input === 'object') {
  4608. if (input.file_path) return `${verb} ${input.file_path.split('/').pop()}`;
  4609. if (input.pattern) return `${verb} "${input.pattern}"`;
  4610. if (input.path) return `${verb} ${input.path.split('/').pop()}`;
  4611. }
  4612. if (input && typeof input === 'string' && input.length < 60) return `${verb} ${input}`;
  4613. return verb;
  4614. }
  4615. function _toolCallSummary(name, input) {
  4616. if (!input) return '';
  4617. const inp = typeof input === 'object' ? input : {};
  4618. switch (name) {
  4619. case 'Bash': return (inp.command || '').substring(0, 100);
  4620. case 'ReadFile': return inp.file_path || '';
  4621. case 'WriteFile': return inp.file_path || '';
  4622. case 'EditFile': return inp.file_path || '';
  4623. case 'Glob': return inp.pattern || '';
  4624. case 'Grep': return `"${inp.pattern || ''}" in ${inp.path || '.'}`;
  4625. case 'VLCompile': case 'VLParse': return 'project';
  4626. case 'VLMetadata': return inp.action || '';
  4627. case 'VLValidate': return inp.file_path || 'all';
  4628. default:
  4629. if (inp.file_path) return inp.file_path;
  4630. if (inp.command) return inp.command.substring(0, 80);
  4631. return '';
  4632. }
  4633. }
  4634. function updateChatElapsed() {
  4635. if (!_chatStartTime) return;
  4636. const sec = Math.round((Date.now() - _chatStartTime) / 1000);
  4637. $('csElapsed').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m${sec%60}s`;
  4638. }
  4639. // ===================== PLAN MODE =====================
  4640. let _planModeActive = false;
  4641. function togglePlanMode() {
  4642. if (_planModeActive) {
  4643. cancelPlan();
  4644. } else {
  4645. enterPlanMode();
  4646. }
  4647. }
  4648. async function enterPlanMode() {
  4649. try {
  4650. await fetch('/api/plan/enter', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
  4651. _planModeActive = true;
  4652. $('planModeBar').style.display = 'flex';
  4653. $('planApproveBtn').style.display = 'none';
  4654. $('planModeToggle').classList.add('active');
  4655. $('chatInput').placeholder = 'Describe what you want to explore/plan...';
  4656. setStatus('Plan Mode (read-only)', 'yellow');
  4657. } catch (e) {
  4658. console.error('enterPlanMode error:', e);
  4659. }
  4660. }
  4661. async function approvePlan() {
  4662. // Send "approve" as a chat message to trigger plan implementation
  4663. $('chatInput').value = 'approve';
  4664. sendMessage();
  4665. }
  4666. async function cancelPlan() {
  4667. try {
  4668. await fetch('/api/plan/cancel', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
  4669. } catch {}
  4670. _planModeActive = false;
  4671. $('planModeBar').style.display = 'none';
  4672. $('planModeToggle').classList.remove('active');
  4673. $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
  4674. setStatus('Ready', 'green');
  4675. }
  4676. function handlePlanModeEvent(data) {
  4677. if (data.phase === 'enter') {
  4678. _planModeActive = true;
  4679. $('planModeBar').style.display = 'flex';
  4680. $('planApproveBtn').style.display = 'none';
  4681. $('planModeToggle').classList.add('active');
  4682. setStatus('Plan Mode (exploring...)', 'yellow');
  4683. } else if (data.phase === 'ready') {
  4684. // Plan is ready for approval
  4685. $('planApproveBtn').style.display = '';
  4686. document.querySelector('.plan-mode-label').innerHTML = '&#128203; Plan ready — review above';
  4687. setStatus('Plan ready — Approve or Cancel', 'yellow');
  4688. } else if (data.phase === 'exit') {
  4689. _planModeActive = false;
  4690. $('planModeBar').style.display = 'none';
  4691. $('planModeToggle').classList.remove('active');
  4692. $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
  4693. document.querySelector('.plan-mode-label').innerHTML = '&#128270; Explore Mode (read-only)';
  4694. }
  4695. }
  4696. async function sendMessage() {
  4697. const input = $('chatInput');
  4698. const msg = input.value.trim();
  4699. if (!msg && !pendingImages.length) return;
  4700. input.value = '';
  4701. autoResizeChatInput(true);
  4702. $('chatSend').disabled = true;
  4703. $('chatSend').style.display = 'none';
  4704. $('chatStop').style.display = '';
  4705. _currentAbortController = new AbortController();
  4706. setChatStatusRunning(true);
  4707. setStatus('Thinking...', 'yellow');
  4708. $('mentionDropdown').classList.remove('open');
  4709. // Add turn separator in Detail Panel
  4710. const detailBody = $('detailBody');
  4711. if (detailBody) {
  4712. const sep = document.createElement('div');
  4713. sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0 4px;font-size:8px;color:var(--text2);padding-top:2px;';
  4714. sep.textContent = '— ' + new Date().toLocaleTimeString() + ' — new turn —';
  4715. detailBody.appendChild(sep);
  4716. }
  4717. clearStreamBoxes();
  4718. // Track turn boundary: record backend message count before this turn
  4719. const turnStartIdx = _lastBackendMsgCount;
  4720. // Show user message with image previews
  4721. const userMsgEl = addMsg('user', msg, pendingImages.map(i => i.preview));
  4722. userMsgEl.dataset.turnStart = turnStartIdx;
  4723. activeToolGroup = null;
  4724. // Build request body
  4725. const body = { message: msg, chatId: activeConvId };
  4726. if (pendingImages.length) {
  4727. body.images = pendingImages.map(i => ({ data: i.data, mediaType: i.mediaType }));
  4728. }
  4729. if (pendingMentions.length) {
  4730. body.mentions = [...pendingMentions];
  4731. }
  4732. // Clear attachments
  4733. pendingImages = [];
  4734. pendingMentions = [];
  4735. $('chatAttachments').innerHTML = '';
  4736. // Auto-attach context screenshots from previous test runs (sent to LLM)
  4737. if (_contextScreenshots.length) {
  4738. body.images = body.images || [];
  4739. for (const ss of _contextScreenshots) {
  4740. try {
  4741. const resp = await fetch(ss.url);
  4742. const blob = await resp.blob();
  4743. const base64 = await blobToBase64(blob);
  4744. body.images.push({ data: base64, mediaType: 'image/png' });
  4745. } catch {}
  4746. }
  4747. _contextScreenshots = [];
  4748. }
  4749. try {
  4750. const res = await fetch('/api/chat', {
  4751. method:'POST', headers:{'Content-Type':'application/json'},
  4752. body: JSON.stringify(body),
  4753. signal: _currentAbortController?.signal,
  4754. });
  4755. startSpinnerSafetyTimeout();
  4756. const reader = res.body.getReader();
  4757. const decoder = new TextDecoder();
  4758. let assistantEl = null;
  4759. let buffer = '';
  4760. let currentEvent = '';
  4761. let _lastSubAgentId = null;
  4762. let _chatCurrentTool = null;
  4763. while (true) {
  4764. const {done, value} = await reader.read();
  4765. if (done) break;
  4766. buffer += decoder.decode(value, {stream:true});
  4767. const lines = buffer.split('\n');
  4768. buffer = lines.pop();
  4769. for (const line of lines) {
  4770. if (line.startsWith('event: ')) {
  4771. currentEvent = line.slice(7);
  4772. continue;
  4773. }
  4774. if (line.startsWith('data: ')) {
  4775. try {
  4776. const data = JSON.parse(line.slice(6));
  4777. debugLog(currentEvent || 'data', data);
  4778. // Thinking indicator
  4779. if (currentEvent === 'thinking') {
  4780. if (data.phase === 'start') {
  4781. addThinkingIndicator();
  4782. updateChatStatusBar('Thinking deeply...', '');
  4783. setStatus('Thinking deeply...', 'yellow');
  4784. addDetailEntry('thinking', 'Extended thinking started...', null, 'info');
  4785. } else if (data.phase === 'delta' && data.text) {
  4786. appendToStreamBox('thinking_stream', 'Thinking', data.text);
  4787. appendThinkingText(data.text);
  4788. } else if (data.phase === 'end') {
  4789. finalizeThinking();
  4790. }
  4791. }
  4792. // Retry indicator
  4793. else if (currentEvent === 'retry') {
  4794. addRetryIndicator(data.attempt, data.delay, data.status);
  4795. const retryMsg = data.status === 'Overloaded'
  4796. ? `API overloaded, retrying (${data.attempt}/3) in ${Math.round(data.delay/1000)}s...`
  4797. : `Retrying (${data.attempt}/3)...`;
  4798. updateChatStatusBar(retryMsg, '');
  4799. setStatus(retryMsg, 'yellow');
  4800. addDetailEntry('retry', retryMsg, null, 'warn');
  4801. }
  4802. // Text token
  4803. else if (data.text) {
  4804. if (!assistantEl) {
  4805. assistantEl = addMsg('assistant', '');
  4806. assistantEl.querySelector('.content-text').dataset.raw = '';
  4807. addDetailEntry('response', 'LLM response streaming...', null, 'info');
  4808. }
  4809. appendToStreamBox('response_stream', 'Response', data.text);
  4810. const textEl = assistantEl.querySelector('.content-text');
  4811. textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
  4812. textEl.textContent += data.text;
  4813. updateChatStatusBar('Responding...', '');
  4814. scrollChat();
  4815. }
  4816. // Tool call - compact indicator
  4817. else if (data.name && data.input !== undefined) {
  4818. _chatCurrentTool = data.name;
  4819. const _linkId = 'tl_' + (++_detailLinkId);
  4820. addToolIndicator(data.name, data.input, 'running', data.detail, _linkId);
  4821. updateChatStatusBar(toolToVerb(data.name, data.input), '');
  4822. setStatus(`${data.name}...`, 'yellow');
  4823. // Detail Panel: full tool info (not sent to LLM — no need to truncate)
  4824. const inputStr = typeof data.input === 'string' ? data.input : JSON.stringify(data.input);
  4825. if (data.name === 'SubAgent') {
  4826. const agentLabel = inputStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
  4827. const agentId = 'agent_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
  4828. getOrCreateAgentGroup(agentId, agentLabel);
  4829. _lastSubAgentId = agentId;
  4830. } else {
  4831. const toolSummary = _toolCallSummary(data.name, data.input);
  4832. // Always pass full input as expandable data — no truncation
  4833. addDetailEntry('tool', `${data.name} ${toolSummary}`, inputStr, 'info', { depth: 0, linkId: _linkId });
  4834. }
  4835. }
  4836. // Tool result - update indicator
  4837. else if (data.name && data.preview !== undefined) {
  4838. updateToolIndicator(data.name, data.preview, data.diff);
  4839. updateChatStatusBar('Thinking...', data.name + ' done');
  4840. // Auto-activate preview when VLParse returns preview URLs
  4841. if (data.name === 'VLParse' && data.preview.includes('Preview URLs')) {
  4842. loadPreviewUrlsFromProfile();
  4843. }
  4844. // Detail Panel: full result — no truncation
  4845. const resultStr = data.detail || (typeof data.preview === 'string' ? data.preview : JSON.stringify(data.preview));
  4846. if (data.name === 'SubAgent' && _lastSubAgentId) {
  4847. completeAgentGroup(_lastSubAgentId, 'done');
  4848. addDetailEntry('result', resultStr, null, 'success', { depth: 1 });
  4849. } else {
  4850. // Use same linkId as the tool call (last assigned)
  4851. const resultLinkId = 'tl_' + _detailLinkId;
  4852. addDetailEntry('result', `${data.name}`, resultStr, 'success', { depth: 0, linkId: resultLinkId });
  4853. }
  4854. }
  4855. // AskUserQuestion widget
  4856. else if (currentEvent === 'ask_user') {
  4857. showAskUserWidget(data);
  4858. updateChatStatusBar('Waiting for your answer...', '');
  4859. setStatus('Waiting for your answer...', 'yellow');
  4860. }
  4861. // Plan Mode events
  4862. else if (currentEvent === 'plan_mode') {
  4863. handlePlanModeEvent(data);
  4864. }
  4865. // Todos
  4866. else if (data.todos) {
  4867. renderTodos(data.todos);
  4868. }
  4869. // Workflow events — show approval UI in chat + load into flow editor
  4870. else if (currentEvent === 'workflow_generated') {
  4871. addWorkflowApproval(data);
  4872. if (data.workflow) {
  4873. showModeIframe('workflow', '/workflow-editor.html', async () => {
  4874. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
  4875. });
  4876. }
  4877. }
  4878. else if (currentEvent === 'workflow_start') {
  4879. forwardWorkflowEventToIframe('workflow_start', data);
  4880. const wfName = data.name || '';
  4881. _lastWorkflowName = wfName;
  4882. _lastRunCheckpoint = null; // Reset for new run
  4883. // Clear previous step cards
  4884. for (const k in _stepCards) delete _stepCards[k];
  4885. if (wfName.startsWith('autotest')) switchFlowTab('autotest');
  4886. else if (wfName.includes('codegen') || wfName.includes('generate')) switchFlowTab('generate');
  4887. else switchFlowTab('adjust');
  4888. if (wfName) loadWorkflowIntoFlowTab(wfName);
  4889. const wfModel = data.model ? ` [${data.model}]` : '';
  4890. addDetailEntry('workflow', `► Workflow started: ${wfName}${wfModel} (${data.stepCount || '?'} steps)`, null, 'info');
  4891. if (!assistantEl) assistantEl = addMsg('assistant', '');
  4892. 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>`;
  4893. scrollChat();
  4894. }
  4895. else if (currentEvent === 'node_start') {
  4896. forwardWorkflowEventToIframe('node_start', data);
  4897. updateWfProgressNode(data.nodeId, 'running');
  4898. const nodeLabel = data.title || data.nodeId || '?';
  4899. const nodeType = data.type || '';
  4900. const typeBadge = nodeType ? `[${nodeType}] ` : '';
  4901. // Use enhanced step card in detail log
  4902. addStepCard(data.nodeId, nodeType, nodeLabel, data.resolvedInputs || data.input);
  4903. updateChatStatusBar(`Running ${nodeLabel}...`, '');
  4904. // Show workflow step in chat for visibility
  4905. if (!assistantEl) assistantEl = addMsg('assistant', '');
  4906. const stepLine = document.createElement('div');
  4907. stepLine.className = 'wf-chat-step';
  4908. stepLine.id = `wf-step-${data.nodeId}`;
  4909. stepLine.style.cssText = 'font-size:11px;color:var(--text2);padding:2px 0;';
  4910. stepLine.textContent = `▶ ${typeBadge}${nodeLabel}`;
  4911. assistantEl.querySelector('.content-text').appendChild(stepLine);
  4912. scrollChat();
  4913. }
  4914. else if (currentEvent === 'node_done') {
  4915. forwardWorkflowEventToIframe('node_done', data);
  4916. updateWfProgressNode(data.nodeId, 'done');
  4917. const doneLabel = data.title || data.nodeId || '?';
  4918. const duration = data.duration_ms ? ` (${data.duration_ms >= 1000 ? (data.duration_ms / 1000).toFixed(1) + 's' : data.duration_ms + 'ms'})` : '';
  4919. // Complete step card with outputs
  4920. completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
  4921. // Update chat step line
  4922. const chatStep = document.getElementById(`wf-step-${data.nodeId}`);
  4923. if (chatStep) { chatStep.style.color = 'var(--green)'; chatStep.textContent = `✓ ${doneLabel}${duration}`; }
  4924. }
  4925. else if (currentEvent === 'node_error') {
  4926. forwardWorkflowEventToIframe('node_error', data);
  4927. updateWfProgressNode(data.nodeId, 'error');
  4928. const errLabel = data.title || data.nodeId || '?';
  4929. const errDur = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(1)}s)` : '';
  4930. // Error step card
  4931. errorStepCard(data.nodeId, data.error || data.detail || 'Unknown error', data.duration_ms);
  4932. if (!assistantEl) assistantEl = addMsg('assistant', '');
  4933. const errLine = document.createElement('div');
  4934. errLine.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
  4935. errLine.textContent = '✗ Error in ' + errLabel + errDur + ': ' + (data.error || 'unknown');
  4936. assistantEl.querySelector('.content-text').appendChild(errLine);
  4937. scrollChat();
  4938. }
  4939. else if (currentEvent === 'node_skipped') {
  4940. forwardWorkflowEventToIframe('node_skipped', data);
  4941. addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
  4942. }
  4943. // Workflow pause — show resume/cancel UI in chat
  4944. else if (currentEvent === 'pause') {
  4945. forwardWorkflowEventToIframe('pause', data);
  4946. updateWfProgressNode(data.nodeId, 'paused');
  4947. addPauseResumeUI(data.nodeId, data.title || data.reason || data.nodeId, data.runID || _currentRunID);
  4948. addDetailEntry('workflow', `⏸ Paused: ${data.title || data.nodeId}`, null, 'warn');
  4949. }
  4950. else if (currentEvent === 'resumed') {
  4951. forwardWorkflowEventToIframe('resumed', data);
  4952. updateWfProgressNode(data.nodeId, 'running');
  4953. addDetailEntry('workflow', `▶ Resumed: ${data.nodeId}`, null, 'info');
  4954. }
  4955. // ── Extended LLM communication events (workflow internal) ──
  4956. else if (currentEvent === 'llm_thinking') {
  4957. appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
  4958. }
  4959. else if (currentEvent === 'llm_tool_use') {
  4960. // Show full tool input as expandable JSON
  4961. const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  4962. addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInputStr, 'info', { depth: 1 });
  4963. updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
  4964. }
  4965. else if (currentEvent === 'llm_tool_result') {
  4966. const isErr = data.is_error || false;
  4967. const rc = data.content || '';
  4968. const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
  4969. // Always show result as expandable data (not just >120 chars)
  4970. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${data.name || 'Result'}${data.tool_use_id ? ' [' + data.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
  4971. }
  4972. else if (currentEvent === 'tool_start') {
  4973. forwardWorkflowEventToIframe('tool_start', data);
  4974. const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  4975. addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInputStr, 'info', { depth: 1 });
  4976. updateChatStatusBar(`Tool step: ${data.name || '?'}`, '');
  4977. }
  4978. else if (currentEvent === 'tool_done') {
  4979. forwardWorkflowEventToIframe('tool_done', data);
  4980. const toolOutputStr = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
  4981. addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutputStr, 'success', { depth: 1 });
  4982. }
  4983. else if (currentEvent === 'tool_error') {
  4984. forwardWorkflowEventToIframe('tool_error', data);
  4985. addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
  4986. }
  4987. else if (currentEvent === 'tool_message') {
  4988. forwardWorkflowEventToIframe('tool_message', data);
  4989. const toolDetailStr = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  4990. addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetailStr, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  4991. }
  4992. else if (currentEvent === 'llm_done') {
  4993. flushStreamBoxes();
  4994. const mdl = data.model || '';
  4995. const usg = data.usage || {};
  4996. const inTok = usg.input_tokens || usg.prompt_tokens || 0;
  4997. const outTok = usg.output_tokens || usg.completion_tokens || 0;
  4998. const cacheTok = usg.cache_read_input_tokens || 0;
  4999. const lat = data.latency_ms ? `${(data.latency_ms / 1000).toFixed(1)}s` : '';
  5000. const tokenParts = [];
  5001. if (inTok) tokenParts.push(`in:${inTok}`);
  5002. if (cacheTok) tokenParts.push(`cache:${cacheTok}`);
  5003. if (outTok) tokenParts.push(`out:${outTok}`);
  5004. const parts = [mdl, tokenParts.join(' '), lat].filter(Boolean).join(' | ');
  5005. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  5006. }
  5007. else if (currentEvent === 'llm_error') {
  5008. const errInfo = [data.error || 'Unknown'];
  5009. if (data.type) errInfo.push(`type:${data.type}`);
  5010. if (data.code) errInfo.push(`code:${data.code}`);
  5011. if (data.latency_ms) errInfo.push(`${(data.latency_ms / 1000).toFixed(1)}s`);
  5012. addDetailEntry('llm', `✗ LLM Error${data.retryable ? ' (retryable)' : ''}: ${errInfo.join(' | ')}`, data, 'error');
  5013. // Show LLM errors in chat too
  5014. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5015. const errDiv = document.createElement('div');
  5016. errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
  5017. errDiv.textContent = `✗ LLM Error: ${data.error || 'Unknown'}`;
  5018. assistantEl.querySelector('.content-text').appendChild(errDiv);
  5019. scrollChat();
  5020. }
  5021. else if (currentEvent === 'var_changed') {
  5022. const vn = data.name || '?';
  5023. const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 120) : '—';
  5024. const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 120) : '—';
  5025. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
  5026. }
  5027. else if (currentEvent === 'file_start') {
  5028. addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
  5029. }
  5030. else if (currentEvent === 'file_written') {
  5031. const fp = data.path || '?';
  5032. addDetailEntry('file', `✓ Written: ${fp}`, null, 'success', { depth: 1 });
  5033. // Associate file with current running step card
  5034. const runningStep = getCurrentRunningStepID();
  5035. if (runningStep) addFileToStepCard(runningStep, fp);
  5036. // Trigger file tree refresh
  5037. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  5038. window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
  5039. _generatedFileCount++;
  5040. }
  5041. else if (currentEvent === 'checkpoint') {
  5042. // Store checkpoint for potential rerun
  5043. _lastRunCheckpoint = data.checkpoint || data;
  5044. addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} steps done)`, null, 'info', { depth: 1 });
  5045. }
  5046. // Screenshots — display inline in chat + add to LLM context
  5047. else if (currentEvent === 'screenshot' && data.screenshots?.length) {
  5048. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5049. for (const ssName of data.screenshots) {
  5050. const url = `/api/browser/screenshot/${ssName}`;
  5051. appendScreenshotToChat(assistantEl, url, ssName);
  5052. _contextScreenshots.push({ url, name: ssName });
  5053. }
  5054. }
  5055. // Done — finalize markdown rendering
  5056. else if (currentEvent === 'done') {
  5057. finalizeAssistantMsg(assistantEl);
  5058. activeToolGroup = null;
  5059. finalizeAllToolSpinners();
  5060. flushStreamBoxes();
  5061. clearSpinnerSafetyTimeout();
  5062. // Track turn end boundary for context exclusion
  5063. if (data.msgCount !== undefined) {
  5064. _lastBackendMsgCount = data.msgCount;
  5065. // Stamp turn boundaries on user message element
  5066. if (userMsgEl) {
  5067. userMsgEl.dataset.turnEnd = data.msgCount - 1;
  5068. }
  5069. }
  5070. // Auto-generate conversation title after first turn
  5071. autoTitleConversation(activeConvId, msg);
  5072. // Auto-compile if VL files were written during this turn
  5073. if (_generatedFileCount > 0) {
  5074. addMsg('assistant', `${_generatedFileCount} VL file(s) written — auto-compiling...`);
  5075. compileProject();
  5076. }
  5077. // Push DOM snapshot to server immediately so other windows can sync
  5078. pushChatStateToServer();
  5079. }
  5080. // Error
  5081. else if (data.message && currentEvent === 'error') {
  5082. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5083. assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
  5084. addDetailEntry('error', data.message, null, 'error');
  5085. }
  5086. } catch {}
  5087. }
  5088. }
  5089. }
  5090. } catch(e) {
  5091. if (e.name === 'AbortError') {
  5092. addMsg('assistant', '⏹ Stopped by user.');
  5093. } else {
  5094. addMsg('assistant', 'Connection error: ' + e.message);
  5095. }
  5096. finalizeAllToolSpinners();
  5097. }
  5098. clearSpinnerSafetyTimeout();
  5099. _currentAbortController = null;
  5100. $('chatStop').style.display = 'none';
  5101. $('chatSend').style.display = '';
  5102. $('chatSend').disabled = false;
  5103. setChatStatusRunning(false);
  5104. setStatus('Ready', 'green');
  5105. updateContext();
  5106. }
  5107. function formatMsgTime(date) {
  5108. const now = new Date();
  5109. const d = date || now;
  5110. const hms = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
  5111. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  5112. const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  5113. const diffDays = Math.round((today - msgDay) / 86400000);
  5114. let prefix;
  5115. if (diffDays === 0) prefix = 'Today';
  5116. else if (diffDays === 1) prefix = 'Yesterday';
  5117. else prefix = `${d.getMonth() + 1}/${d.getDate()}`;
  5118. return `${prefix} ${hms}`;
  5119. }
  5120. function addMsg(role, text, imagePreviews, timestamp) {
  5121. const container = $('chatMessages');
  5122. const div = document.createElement('div');
  5123. div.className = 'msg ' + role;
  5124. div.style.position = 'relative';
  5125. const content = role === 'assistant' ? renderMarkdown(text) : escapeHtml(text);
  5126. const msgDate = timestamp ? new Date(timestamp) : new Date();
  5127. div.dataset.timestamp = msgDate.toISOString();
  5128. const timeStr = formatMsgTime(msgDate);
  5129. let html = `<div class="label">${role} <span class="msg-time">${timeStr}</span></div><span class="content-text">${content}</span>`;
  5130. // Context toggle button (excludes entire turn from LLM context)
  5131. html += `<button class="msg-ctx-toggle" onclick="toggleMsgContext(this)" title="Toggle: include/exclude this turn from LLM context">ctx</button>`;
  5132. // Show image thumbnails in user messages
  5133. if (imagePreviews?.length) {
  5134. html += '<div class="msg-images">';
  5135. for (const src of imagePreviews) html += `<img src="${src}" onclick="window.open(this.src)">`;
  5136. html += '</div>';
  5137. }
  5138. div.innerHTML = html;
  5139. container.appendChild(div);
  5140. scrollChat();
  5141. return div;
  5142. }
  5143. /** Finalize assistant message: re-render as markdown + add Apply buttons to code blocks */
  5144. function finalizeAssistantMsg(el) {
  5145. if (!el) return;
  5146. const textEl = el.querySelector('.content-text');
  5147. if (textEl) {
  5148. const raw = textEl.dataset.raw || textEl.textContent;
  5149. textEl.innerHTML = renderMarkdown(raw);
  5150. // Add Apply buttons to code blocks
  5151. textEl.querySelectorAll('pre').forEach(pre => {
  5152. const btn = document.createElement('button');
  5153. btn.className = 'code-apply';
  5154. btn.textContent = 'Apply';
  5155. btn.onclick = () => applyCodeBlock(pre);
  5156. pre.style.position = 'relative';
  5157. pre.appendChild(btn);
  5158. });
  5159. // All messages shown in full — no truncation
  5160. }
  5161. }
  5162. /** Apply a code block to the current file */
  5163. async function applyCodeBlock(preEl) {
  5164. if (!currentFile) { setStatus('No file open to apply to', 'red'); return; }
  5165. const code = preEl.querySelector('code')?.textContent || preEl.textContent;
  5166. const editorContent = cmEditor ? cmEditor.getValue() : $('editor').value;
  5167. // Show inline diff
  5168. showInlineDiff(currentFile, editorContent, code, () => {
  5169. // Accept: write to file
  5170. if (cmEditor) cmEditor.setValue(code);
  5171. else $('editor').value = code;
  5172. const info = openFiles.get(currentFile);
  5173. if (info && info.type === 'file') info.content = code;
  5174. else openFiles.set(currentFile, { type: 'file', content: code });
  5175. saveCurrentFile();
  5176. setStatus('Applied to ' + currentFile.split('/').pop(), 'green');
  5177. });
  5178. }
  5179. /** Show inline diff block in chat */
  5180. function showInlineDiff(filePath, oldText, newText, onAccept) {
  5181. const container = $('chatMessages');
  5182. const div = document.createElement('div');
  5183. div.className = 'diff-block';
  5184. const oldLines = oldText.split('\n');
  5185. const newLines = newText.split('\n');
  5186. let diffHtml = '';
  5187. // Simple line-by-line diff
  5188. const maxLen = Math.max(oldLines.length, newLines.length);
  5189. for (let i = 0; i < maxLen; i++) {
  5190. const ol = oldLines[i], nl = newLines[i];
  5191. if (ol === nl) {
  5192. if (ol !== undefined) diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(ol)}</div>`;
  5193. } else {
  5194. if (ol !== undefined) diffHtml += `<div class="diff-line diff-del">${escapeHtml(ol)}</div>`;
  5195. if (nl !== undefined) diffHtml += `<div class="diff-line diff-add">${escapeHtml(nl)}</div>`;
  5196. }
  5197. }
  5198. div.innerHTML = `
  5199. <div class="diff-header">
  5200. <span class="diff-file">${escapeHtml(filePath)}</span>
  5201. <div class="diff-actions">
  5202. <button class="diff-accept" id="diffAccept">Accept</button>
  5203. <button class="diff-reject" id="diffReject">Reject</button>
  5204. </div>
  5205. </div>
  5206. <div class="diff-body">${diffHtml}</div>`;
  5207. container.appendChild(div);
  5208. scrollChat();
  5209. div.querySelector('.diff-accept').onclick = () => { onAccept(); div.remove(); };
  5210. div.querySelector('.diff-reject').onclick = () => { div.remove(); setStatus('Changes rejected', 'yellow'); };
  5211. }
  5212. // Claude Code-style compact tool indicator
  5213. let _generatedFileCount = 0;
  5214. let _subAgentCount = 0;
  5215. // Tool name → icon mapping
  5216. const TOOL_ICONS = {
  5217. Read: '&#128196;', ReadFile: '&#128196;', Glob: '&#128269;', Grep: '&#128270;',
  5218. Edit: '&#9998;', EditFile: '&#9998;', Write: '&#128221;', WriteFile: '&#128221;',
  5219. Bash: '&#9654;', VLParse: '&#9881;', VLValidate: '&#10003;', VLImpact: '&#9889;',
  5220. VLMetadata: '&#128202;', VLSection: '&#9638;', SubAgent: '&#9881;', Agent: '&#9881;',
  5221. };
  5222. function getToolIcon(name) { return TOOL_ICONS[name] || '&#9654;'; }
  5223. function addToolIndicator(name, desc, status, detail, linkId) {
  5224. const container = $('chatMessages');
  5225. const descStr = typeof desc === 'string' ? desc : JSON.stringify(desc);
  5226. // For WriteFile: group all writes into a single summary
  5227. if (name === 'WriteFile') {
  5228. _generatedFileCount++;
  5229. let summary = container.querySelector('.tool-files-summary');
  5230. if (!summary) {
  5231. summary = document.createElement('div');
  5232. summary.className = 'tool-group tool-files-summary';
  5233. summary.innerHTML = `
  5234. <div class="tool-header" onclick="toggleToolBody(this)">
  5235. <div class="tool-spinner"></div>
  5236. <span class="tool-name">WriteFile</span>
  5237. <span class="tool-desc file-count-desc">Writing files... (${_generatedFileCount})</span>
  5238. <span class="tool-toggle">&#9654;</span>
  5239. </div>
  5240. <div class="tool-body file-list-body" style="display:block;padding:4px 8px;"></div>`;
  5241. container.appendChild(summary);
  5242. }
  5243. summary.querySelector('.file-count-desc').textContent = `Writing files... (${_generatedFileCount})`;
  5244. const entry = document.createElement('div');
  5245. entry.style.cssText = 'color:var(--green);font-size:10px;padding:1px 0;';
  5246. entry.textContent = `+ ${descStr}`;
  5247. summary.querySelector('.file-list-body').appendChild(entry);
  5248. activeToolGroup = summary;
  5249. scrollChat();
  5250. return;
  5251. }
  5252. // For SubAgent: group into a progress list showing parallel status
  5253. if (name === 'SubAgent') {
  5254. _subAgentCount++;
  5255. let agentSummary = container.querySelector('.tool-agents-summary');
  5256. if (!agentSummary) {
  5257. agentSummary = document.createElement('div');
  5258. agentSummary.className = 'tool-group tool-agents-summary';
  5259. agentSummary.innerHTML = `
  5260. <div class="tool-header" onclick="toggleToolBody(this)">
  5261. <div class="tool-spinner"></div>
  5262. <span class="tool-name">Parallel Agents</span>
  5263. <span class="tool-desc agent-count-desc">Running ${_subAgentCount} agent(s) in parallel...</span>
  5264. <span class="tool-toggle">&#9654;</span>
  5265. </div>
  5266. <div class="tool-body agent-list-body" style="display:block;padding:4px 8px;"></div>`;
  5267. container.appendChild(agentSummary);
  5268. }
  5269. const runningCount = agentSummary.querySelectorAll('.agent-step:not([data-done])').length + 1;
  5270. agentSummary.querySelector('.agent-count-desc').textContent = `Running ${runningCount} agent(s) in parallel...`;
  5271. const entry = document.createElement('div');
  5272. entry.className = 'agent-step';
  5273. entry.dataset.idx = _subAgentCount;
  5274. entry.dataset.startTime = Date.now();
  5275. // Extract meaningful label from prompt
  5276. const label = descStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
  5277. 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>`;
  5278. agentSummary.querySelector('.agent-list-body').appendChild(entry);
  5279. activeToolGroup = agentSummary;
  5280. scrollChat();
  5281. // Update timer
  5282. const timerId = setInterval(() => {
  5283. if (entry.dataset.done) { clearInterval(timerId); return; }
  5284. const elapsed = ((Date.now() - parseInt(entry.dataset.startTime)) / 1000).toFixed(0);
  5285. const timer = entry.querySelector('.agent-timer');
  5286. if (timer) timer.textContent = elapsed + 's';
  5287. }, 1000);
  5288. return;
  5289. }
  5290. const group = document.createElement('div');
  5291. group.className = 'tool-group';
  5292. group.dataset.toolName = name;
  5293. group.dataset.startTime = Date.now();
  5294. if (linkId) {
  5295. group.dataset.linkId = linkId;
  5296. // Click tool in chat → open detail panel & highlight corresponding entry
  5297. group.addEventListener('click', () => scrollToDetailEntry(linkId));
  5298. }
  5299. // Build detail body based on structured detail from server
  5300. let detailHtml = '';
  5301. if (detail) {
  5302. if (detail.type === 'edit' && detail.preview?.length) {
  5303. detailHtml = detail.preview.map(e =>
  5304. `<div class="tool-diff"><div class="td-old">- ${escapeHtml(e.old)}</div><div class="td-new">+ ${escapeHtml(e.new)}</div></div>`
  5305. ).join('') + (detail.editCount > 2 ? `<div class="tool-detail"><span class="td-label">total:</span> <span class="td-val">${detail.editCount} edits</span></div>` : '');
  5306. } else if (detail.type === 'grep') {
  5307. detailHtml = `<div class="tool-detail"><span class="td-label">pattern:</span> <span class="td-val">${escapeHtml(detail.pattern)}</span></div>` +
  5308. `<div class="tool-detail"><span class="td-label">path:</span> <span class="td-val">${escapeHtml(detail.path)}</span></div>`;
  5309. } else if (detail.type === 'vlparse') {
  5310. detailHtml = `<div class="tool-detail"><span class="td-label">action:</span> <span class="td-val">${escapeHtml(detail.action || 'parse')}</span></div>` +
  5311. `<div class="tool-detail"><span class="td-label">cookie:</span> <span class="td-val">${detail.cookie}</span></div>`;
  5312. } else if (detail.type === 'write') {
  5313. detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines}</span></div>`;
  5314. } else if (detail.type === 'vledit') {
  5315. detailHtml = `<div class="tool-detail"><span class="td-label">edits:</span> <span class="td-val">${detail.editCount} change(s)</span></div>`;
  5316. } else if (detail.type === 'read') {
  5317. detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines || '?'}</span></div>`;
  5318. } else if (detail.type === 'other' && detail.raw) {
  5319. detailHtml = `<div class="tool-detail" style="opacity:0.6">${escapeHtml(detail.raw)}</div>`;
  5320. }
  5321. }
  5322. const icon = getToolIcon(name);
  5323. group.innerHTML = `
  5324. <div class="tool-header" onclick="toggleToolBody(this)">
  5325. <div class="tool-spinner"></div>
  5326. <span class="tool-icon">${icon}</span>
  5327. <span class="tool-name">${escapeHtml(name)}</span>
  5328. <span class="tool-desc">${escapeHtml(descStr)}</span>
  5329. <span class="tool-time"></span>
  5330. <span class="tool-toggle">&#9654;</span>
  5331. </div>
  5332. <div class="tool-body${detailHtml ? ' open' : ''}">${detailHtml}</div>`;
  5333. // Auto-open toggle if detail present
  5334. if (detailHtml) {
  5335. const toggle = group.querySelector('.tool-toggle');
  5336. if (toggle) toggle.classList.add('open');
  5337. }
  5338. // Start elapsed timer
  5339. const _timerEl = group.querySelector('.tool-time');
  5340. const _timerStart = Date.now();
  5341. group._timerId = setInterval(() => {
  5342. const elapsed = ((Date.now() - _timerStart) / 1000).toFixed(1);
  5343. if (_timerEl) _timerEl.textContent = elapsed + 's';
  5344. }, 200);
  5345. container.appendChild(group);
  5346. activeToolGroup = group;
  5347. scrollChat();
  5348. }
  5349. function updateToolIndicator(name, result, diffData) {
  5350. // For WriteFile: update the summary group's count label
  5351. if (name === 'WriteFile') {
  5352. const summary = document.querySelector('.tool-files-summary');
  5353. if (summary) {
  5354. summary.querySelector('.file-count-desc').textContent = `${_generatedFileCount} files written`;
  5355. }
  5356. return;
  5357. }
  5358. // For SubAgent: mark the latest unfinished step as done
  5359. if (name === 'SubAgent') {
  5360. const agentSummary = document.querySelector('.tool-agents-summary');
  5361. if (agentSummary) {
  5362. const steps = agentSummary.querySelectorAll('.agent-step');
  5363. // Find first step that hasn't been marked done
  5364. for (let i = 0; i < steps.length; i++) {
  5365. if (!steps[i].dataset.done) {
  5366. steps[i].dataset.done = '1';
  5367. const dot = steps[i].querySelector('.agent-dot');
  5368. if (dot) { dot.style.background = 'var(--green)'; dot.style.animation = 'none'; }
  5369. // Show elapsed time
  5370. const startTime = parseInt(steps[i].dataset.startTime || 0);
  5371. if (startTime) {
  5372. const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  5373. const timer = steps[i].querySelector('.agent-timer');
  5374. if (timer) timer.textContent = elapsed + 's';
  5375. }
  5376. break;
  5377. }
  5378. }
  5379. // Update header with counts
  5380. const doneCount = agentSummary.querySelectorAll('.agent-step[data-done]').length;
  5381. const totalCount = steps.length;
  5382. const runningCount = totalCount - doneCount;
  5383. if (runningCount > 0) {
  5384. agentSummary.querySelector('.agent-count-desc').textContent = `${runningCount} running, ${doneCount}/${totalCount} done`;
  5385. } else {
  5386. agentSummary.querySelector('.agent-count-desc').textContent = `All ${totalCount} agents done`;
  5387. // Finalize spinner
  5388. const spinner = agentSummary.querySelector('.tool-spinner');
  5389. if (spinner) {
  5390. const parent = spinner.parentNode;
  5391. spinner.remove();
  5392. const icon = document.createElement('div');
  5393. icon.className = 'tool-icon';
  5394. icon.style.color = 'var(--green)';
  5395. icon.textContent = '\u2713';
  5396. parent.prepend(icon);
  5397. }
  5398. }
  5399. }
  5400. return;
  5401. }
  5402. // Find the most recent tool group matching name
  5403. const groups = document.querySelectorAll('.tool-group');
  5404. let target = null;
  5405. for (let i = groups.length - 1; i >= 0; i--) {
  5406. if (groups[i].dataset.toolName === name) {
  5407. target = groups[i]; break;
  5408. }
  5409. }
  5410. if (!target) return;
  5411. // Stop timer
  5412. if (target._timerId) { clearInterval(target._timerId); target._timerId = null; }
  5413. // Replace spinner with check/x icon
  5414. const spinner = target.querySelector('.tool-spinner');
  5415. if (spinner) {
  5416. spinner.remove();
  5417. const doneIcon = document.createElement('span');
  5418. doneIcon.className = 'tool-status-icon done';
  5419. doneIcon.innerHTML = '&#10003;';
  5420. target.querySelector('.tool-header').prepend(doneIcon);
  5421. }
  5422. // Add result badge to header desc
  5423. const desc = target.querySelector('.tool-desc');
  5424. if (desc && result) {
  5425. const shortResult = result.length > 60 ? result.substring(0, 60) + '...' : result;
  5426. if (name === 'ReadFile') {
  5427. desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">${escapeHtml(result)}</span>`;
  5428. } else if (name === 'VLValidate' && (result.includes('valid') || result.includes('pass'))) {
  5429. desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">passed</span>`;
  5430. }
  5431. }
  5432. // Set body content (preserve existing detail if result is short)
  5433. const body = target.querySelector('.tool-body');
  5434. // EditFile: show inline diff + undo button
  5435. if (diffData && diffData.diff) {
  5436. body.innerHTML = '';
  5437. const diffEl = document.createElement('div');
  5438. diffEl.className = 'edit-diff-preview';
  5439. const lines = diffData.diff.split('\n');
  5440. let diffHtml = '';
  5441. for (const line of lines) {
  5442. if (line.startsWith('+')) diffHtml += `<div class="diff-line diff-add">${escapeHtml(line)}</div>`;
  5443. else if (line.startsWith('-')) diffHtml += `<div class="diff-line diff-del">${escapeHtml(line)}</div>`;
  5444. else diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(line)}</div>`;
  5445. }
  5446. diffEl.innerHTML = diffHtml;
  5447. body.appendChild(diffEl);
  5448. // Undo button
  5449. if (diffData.undoId) {
  5450. const undoBtn = document.createElement('button');
  5451. undoBtn.className = 'plan-cancel-btn';
  5452. undoBtn.style.cssText = 'margin-top:4px; font-size:10px; padding:2px 8px;';
  5453. undoBtn.textContent = 'Undo';
  5454. undoBtn.onclick = async () => {
  5455. try {
  5456. const r = await fetch(`/api/undo/${diffData.undoId}`, { method: 'POST' });
  5457. const d = await r.json();
  5458. if (d.ok) { undoBtn.textContent = 'Undone'; undoBtn.disabled = true; setStatus(`Undid edit to ${d.file}`, 'green'); }
  5459. else { setStatus(d.error || 'Undo failed', 'red'); }
  5460. } catch (e) { setStatus('Undo error: ' + e.message, 'red'); }
  5461. };
  5462. body.appendChild(undoBtn);
  5463. }
  5464. body.classList.add('open');
  5465. target.querySelector('.tool-toggle')?.classList.add('open');
  5466. } else if (result && result.length > 30) {
  5467. body.textContent = result;
  5468. } else if (result) {
  5469. const badge = document.createElement('div');
  5470. badge.className = 'tool-detail';
  5471. badge.innerHTML = `<span class="td-val" style="color:var(--green)">${escapeHtml(result)}</span>`;
  5472. body.appendChild(badge);
  5473. }
  5474. }
  5475. /** Finalize ALL tool spinners — called when chat response completes */
  5476. function finalizeAllToolSpinners() {
  5477. // Finalize WriteFile summary
  5478. const wfSummary = document.querySelector('.tool-files-summary');
  5479. if (wfSummary) {
  5480. const desc = wfSummary.querySelector('.file-count-desc');
  5481. if (desc) desc.textContent = `${_generatedFileCount} files written`;
  5482. }
  5483. _generatedFileCount = 0;
  5484. // Finalize SubAgent summary
  5485. const agentSummary = document.querySelector('.tool-agents-summary');
  5486. if (agentSummary) {
  5487. const desc = agentSummary.querySelector('.agent-count-desc');
  5488. if (desc) desc.textContent = `${_subAgentCount} agents completed`;
  5489. // Mark all steps as done
  5490. agentSummary.querySelectorAll('.agent-step:not([data-done])').forEach(step => {
  5491. step.dataset.done = '1';
  5492. const marker = step.querySelector('span');
  5493. if (marker) { marker.textContent = '\u25CF'; marker.style.color = 'var(--green)'; }
  5494. });
  5495. }
  5496. _subAgentCount = 0;
  5497. // Finalize ALL remaining spinning tool groups — stop timers, replace spinners
  5498. document.querySelectorAll('.tool-group').forEach(group => {
  5499. if (group._timerId) { clearInterval(group._timerId); group._timerId = null; }
  5500. });
  5501. document.querySelectorAll('.tool-group .tool-spinner').forEach(spinner => {
  5502. const header = spinner.closest('.tool-header');
  5503. spinner.remove();
  5504. const icon = document.createElement('span');
  5505. icon.className = 'tool-status-icon done';
  5506. icon.innerHTML = '&#10003;';
  5507. header?.prepend(icon);
  5508. });
  5509. }
  5510. // Spinner safety timeout — auto-finalize if stream hangs and fully reset UI
  5511. let _spinnerSafetyTimer = null;
  5512. function startSpinnerSafetyTimeout() {
  5513. clearSpinnerSafetyTimeout();
  5514. _spinnerSafetyTimer = setTimeout(() => {
  5515. console.warn('[VL-Code] Spinner safety timeout (120s) — force-finalizing and resetting UI');
  5516. finalizeAllToolSpinners();
  5517. flushStreamBoxes();
  5518. // Full UI reset so the user is never permanently stuck
  5519. _currentAbortController = null;
  5520. try { $('chatStop').style.display = 'none'; } catch {}
  5521. try { $('chatSend').style.display = ''; $('chatSend').disabled = false; } catch {}
  5522. setChatStatusRunning(false);
  5523. setStatus('Timed out — Ready', 'yellow');
  5524. setTimeout(() => setStatus('Ready', 'green'), 3000);
  5525. _spinnerSafetyTimer = null;
  5526. }, 120000); // 2 minutes
  5527. }
  5528. function clearSpinnerSafetyTimeout() {
  5529. if (_spinnerSafetyTimer) { clearTimeout(_spinnerSafetyTimer); _spinnerSafetyTimer = null; }
  5530. }
  5531. function toggleToolBody(header) {
  5532. const body = header.nextElementSibling;
  5533. const toggle = header.querySelector('.tool-toggle');
  5534. body.classList.toggle('open');
  5535. toggle.classList.toggle('open');
  5536. }
  5537. function renderTodos(todos) {
  5538. // Remove existing
  5539. document.querySelectorAll('.msg.todo-list').forEach(e => e.remove());
  5540. if (!todos || todos.length === 0) return;
  5541. const div = document.createElement('div');
  5542. div.className = 'msg todo-list';
  5543. let html = '<div class="label">Tasks</div>';
  5544. for (const t of todos) {
  5545. const cls = t.status === 'completed' ? 'todo-done' : t.status === 'in_progress' ? 'todo-active' : 'todo-pending';
  5546. const icon = t.status === 'completed' ? '\u2713' : t.status === 'in_progress' ? '\u25B6' : '\u25CB';
  5547. const text = t.status === 'in_progress' ? (t.activeForm || t.content) : t.content;
  5548. // Timing info
  5549. let timing = '';
  5550. if (t.status === 'completed' && t.startedAt && t.completedAt) {
  5551. const secs = ((t.completedAt - t.startedAt) / 1000).toFixed(1);
  5552. timing = `<span class="todo-timing">${secs}s</span>`;
  5553. } else if (t.status === 'in_progress' && t.startedAt) {
  5554. timing = `<span class="todo-timing todo-elapsed" data-start="${t.startedAt}">0s</span>`;
  5555. }
  5556. const spinnerHtml = t.status === 'in_progress' ? '<span class="todo-spinner"></span>' : '';
  5557. html += `<div class="todo-item ${cls}">${spinnerHtml}<span class="todo-icon">${icon}</span><span class="todo-text">${escapeHtml(text)}</span>${timing}</div>`;
  5558. // Render subtasks
  5559. if (t.subtasks?.length) {
  5560. for (const st of t.subtasks) {
  5561. const stCls = st.status === 'completed' ? 'todo-done' : st.status === 'in_progress' ? 'todo-active' : 'todo-pending';
  5562. const stIcon = st.status === 'completed' ? '\u2713' : st.status === 'in_progress' ? '\u25B6' : '\u25CB';
  5563. html += `<div class="todo-item todo-subtask ${stCls}"><span class="todo-icon">${stIcon}</span>${escapeHtml(st.content)}</div>`;
  5564. }
  5565. }
  5566. }
  5567. div.innerHTML = html;
  5568. $('chatMessages').appendChild(div);
  5569. scrollChat();
  5570. // Start elapsed time timers
  5571. div.querySelectorAll('.todo-elapsed').forEach(el => {
  5572. const start = parseInt(el.dataset.start);
  5573. if (!start) return;
  5574. el._tid = setInterval(() => {
  5575. el.textContent = ((Date.now() - start) / 1000).toFixed(0) + 's';
  5576. }, 1000);
  5577. });
  5578. }
  5579. let _chatUserScrolled = false;
  5580. function scrollChat() {
  5581. const el = $('chatMessages');
  5582. // Only auto-scroll if user hasn't manually scrolled up
  5583. if (!_chatUserScrolled) {
  5584. el.scrollTop = el.scrollHeight;
  5585. }
  5586. }
  5587. // Detect manual scroll: if user scrolls up, stop auto-scroll; if near bottom, resume
  5588. (function() {
  5589. let _scrollTimer;
  5590. document.addEventListener('DOMContentLoaded', () => {
  5591. const el = $('chatMessages');
  5592. if (!el) return;
  5593. el.addEventListener('scroll', () => {
  5594. clearTimeout(_scrollTimer);
  5595. _scrollTimer = setTimeout(() => {
  5596. const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
  5597. _chatUserScrolled = !atBottom;
  5598. }, 50);
  5599. });
  5600. });
  5601. })();
  5602. // chatInput keydown is handled in @-mention section above
  5603. // ===================== GENERATE =====================
  5604. function openGenerate() { $('genModal').classList.add('open'); }
  5605. function closeGenerate() { $('genModal').classList.remove('open'); }
  5606. /** Focus chat input with generate hint (new approach — no modal) */
  5607. function focusGenerate() {
  5608. const input = $('chatInput');
  5609. const wfName = workflowBindings.generate || '3-file-codegen';
  5610. input.value = `Generate a VL project (workflow: ${wfName}): `;
  5611. input.focus();
  5612. input.setSelectionRange(input.value.length, input.value.length);
  5613. }
  5614. async function startGenerate() {
  5615. const req = $('genInput').value.trim();
  5616. if (!req) return;
  5617. $('genStart').disabled = true;
  5618. $('genProgress').style.display = 'block';
  5619. $('genProgress').innerHTML = '';
  5620. setStatus('Generating...', 'yellow');
  5621. try {
  5622. const res = await fetch('/api/generate', {
  5623. method:'POST', headers:{'Content-Type':'application/json'},
  5624. body: JSON.stringify({ userRequest: req, targetLang: 'en' })
  5625. });
  5626. const reader = res.body.getReader();
  5627. const decoder = new TextDecoder();
  5628. let buffer = '';
  5629. while (true) {
  5630. const {done, value} = await reader.read();
  5631. if (done) break;
  5632. buffer += decoder.decode(value, {stream:true});
  5633. const lines = buffer.split('\n');
  5634. buffer = lines.pop();
  5635. for (const line of lines) {
  5636. if (line.startsWith('data: ')) {
  5637. try {
  5638. const data = JSON.parse(line.slice(6));
  5639. if (data.title) addGenStep('step', data.title);
  5640. if (data.path) addGenStep('file', data.path);
  5641. if (data.filesWritten) { addGenStep('done', `${data.filesWritten.length} files generated`); await loadFileTree(); }
  5642. if (data.message) addGenStep('error', data.message);
  5643. } catch {}
  5644. }
  5645. }
  5646. }
  5647. } catch(e) { addGenStep('error', e.message); }
  5648. $('genStart').disabled = false;
  5649. setStatus('Ready', 'green');
  5650. }
  5651. function addGenStep(type, text) {
  5652. const div = document.createElement('div');
  5653. div.className = 'gen-step';
  5654. const icon = {error:'\u2717', done:'\u2713', file:'\u25A0', step:'\u25B8'}[type] || '\u25B8';
  5655. const color = type === 'error' ? 'var(--red)' : type === 'done' ? 'var(--green)' : 'var(--text2)';
  5656. div.innerHTML = `<span style="color:${color}">${icon}</span> ${escapeHtml(text)}`;
  5657. $('genProgress').appendChild(div);
  5658. }
  5659. /** Run generation via workflow execution with progress visualization in chat */
  5660. async function runGenerateWorkflow() {
  5661. const desc = $('genQuickInput')?.value?.trim();
  5662. if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
  5663. $('genQuickInput')?.closest('.wf-progress')?.remove();
  5664. addMsg('user', desc);
  5665. const wfName = workflowBindings.generate || '3-file-codegen';
  5666. setStatus(`Running workflow: ${wfName}...`, 'yellow');
  5667. addMsg('assistant', `**Running workflow** \`${wfName}\` for: "${desc}"`);
  5668. const progressWidget = addWorkflowProgress(wfName, []);
  5669. try {
  5670. const res = await fetch('/api/workflow/execute', {
  5671. method: 'POST',
  5672. headers: { 'Content-Type': 'application/json' },
  5673. body: JSON.stringify({ workflowName: wfName, params: { userRequest: desc, description: desc } }),
  5674. });
  5675. const reader = res.body.getReader();
  5676. const decoder = new TextDecoder();
  5677. let buffer = '';
  5678. while (true) {
  5679. const { done, value } = await reader.read();
  5680. if (done) break;
  5681. buffer += decoder.decode(value, { stream: true });
  5682. const blocks = buffer.split('\n\n');
  5683. buffer = blocks.pop();
  5684. for (const block of blocks) {
  5685. let eType = 'message', eData = null;
  5686. for (const line of block.split('\n')) {
  5687. if (line.startsWith('event: ')) eType = line.slice(7).trim();
  5688. else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
  5689. }
  5690. if (!eData) continue;
  5691. switch (eType) {
  5692. case 'workflow_start':
  5693. if (eData.name) loadWorkflowIntoFlowTab(eData.name);
  5694. addDetailEntry('workflow', `Workflow started: ${eData.name || ''}`, null, 'info');
  5695. break;
  5696. case 'node_start':
  5697. updateWfProgressNode(eData.nodeId, 'running');
  5698. addDetailEntry('workflow', `▶ ${eData.title || eData.nodeId}`, null, 'info');
  5699. break;
  5700. case 'node_done':
  5701. updateWfProgressNode(eData.nodeId, 'done');
  5702. addDetailEntry('workflow', `✓ ${eData.title || eData.nodeId}`, null, 'success');
  5703. break;
  5704. case 'node_error':
  5705. updateWfProgressNode(eData.nodeId, 'error');
  5706. addDetailEntry('workflow', `✗ ${eData.title || eData.nodeId} — ${eData.error || ''}`, null, 'error');
  5707. break;
  5708. case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
  5709. // ── Extended LLM communication events ──
  5710. case 'llm_thinking':
  5711. appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
  5712. break;
  5713. case 'token':
  5714. appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || '');
  5715. break;
  5716. case 'llm_tool_use':
  5717. addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, eData.input || null, 'info', { depth: 1 });
  5718. updateChatStatusBar(`Tool: ${eData.name || '?'}`, '');
  5719. break;
  5720. case 'llm_tool_result': {
  5721. const isErr = eData.is_error || false;
  5722. const resultContent = eData.content || '';
  5723. const resultStr = typeof resultContent === 'string' ? resultContent : JSON.stringify(resultContent);
  5724. 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 });
  5725. break;
  5726. }
  5727. case 'llm_done': {
  5728. flushStreamBoxes();
  5729. const mdl = eData.model || '';
  5730. const usg = eData.usage || {};
  5731. const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
  5732. const parts = [mdl, usg.input_tokens ? `in:${usg.input_tokens}` : '', usg.output_tokens ? `out:${usg.output_tokens}` : '', lat].filter(Boolean).join(' | ');
  5733. addDetailEntry('llm', `✓ LLM complete — ${parts}`, null, 'success');
  5734. break;
  5735. }
  5736. case 'llm_error':
  5737. addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${eData.error || 'Unknown'}`, eData, 'error');
  5738. break;
  5739. case 'var_changed': {
  5740. const vName = eData.name || '?';
  5741. const vOld = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 80) : '—';
  5742. const vNew = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 80) : '—';
  5743. addDetailEntry('var', `📊 ${vName}: ${vOld} → ${vNew}`, eData, 'info', { depth: 1 });
  5744. break;
  5745. }
  5746. case 'file_start':
  5747. addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
  5748. break;
  5749. case 'pause':
  5750. updateWfProgressNode(eData.nodeId, 'paused');
  5751. addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
  5752. addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
  5753. break;
  5754. case 'resumed':
  5755. updateWfProgressNode(eData.nodeId, 'running');
  5756. addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
  5757. break;
  5758. case 'pause_timeout':
  5759. addDetailEntry('pause', `⏰ Pause timed out → ${eData.timeoutAction || ''}`, eData, 'warn');
  5760. break;
  5761. case 'pause_rejected':
  5762. addDetailEntry('pause', `✗ Resume rejected: ${eData.reason || ''}`, eData, 'error');
  5763. break;
  5764. case 'file_written':
  5765. { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
  5766. break;
  5767. case 'done':
  5768. addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
  5769. addDetailEntry('workflow', 'Workflow completed', null, 'success');
  5770. await loadFileTree();
  5771. await loadProjectInfo();
  5772. setStatus('Generation complete', 'green');
  5773. break;
  5774. case 'error':
  5775. addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
  5776. addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
  5777. setStatus('Workflow error', 'red');
  5778. break;
  5779. }
  5780. }
  5781. }
  5782. } catch (e) {
  5783. addMsg('assistant', '**Workflow failed:** ' + e.message);
  5784. setStatus('Workflow failed', 'red');
  5785. }
  5786. }
  5787. /** Send generate request as normal chat message */
  5788. function sendGenerateAsChat() {
  5789. const desc = $('genQuickInput')?.value?.trim();
  5790. if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
  5791. $('genQuickInput')?.closest('.wf-progress')?.remove();
  5792. const wfName = workflowBindings.generate || '3-file-codegen';
  5793. $('chatInput').value = `Generate a VL project (workflow: ${wfName}): ${desc}`;
  5794. sendMessage();
  5795. }
  5796. // ===================== WORKSPACE STATE =====================
  5797. /** Save full workspace state to .vl-code/workspace.json */
  5798. async function saveWorkspaceState() {
  5799. try {
  5800. const state = {
  5801. savedAt: Date.now(),
  5802. mode: currentMode || 'code',
  5803. activeFile: currentFile || null,
  5804. openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
  5805. debugPanelOpen: $('debugPanel')?.style.display !== 'none',
  5806. chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
  5807. chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
  5808. showInternalFiles,
  5809. wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
  5810. // Chat state is managed by /api/chat/state, not here
  5811. };
  5812. await fetch('/api/workspace/state', {
  5813. method: 'POST',
  5814. headers: { 'Content-Type': 'application/json' },
  5815. body: JSON.stringify(state),
  5816. });
  5817. } catch {}
  5818. }
  5819. /** Restore workspace state from .vl-code/workspace.json */
  5820. async function restoreWorkspaceState() {
  5821. try {
  5822. const state = await api('/api/workspace/state');
  5823. if (!state || !state.savedAt) return false;
  5824. // Restore conversations
  5825. // Chat state is restored by fetchChatStateFromServer() — NOT from workspace.json
  5826. // Restore open files
  5827. if (state.openFilePaths?.length) {
  5828. for (const fp of state.openFilePaths) {
  5829. try {
  5830. const data = await api(`/api/file?path=${encodeURIComponent(fp)}`);
  5831. const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
  5832. openFiles.set(fp, { type: 'file', content });
  5833. } catch {}
  5834. }
  5835. if (state.activeFile && openFiles.has(state.activeFile)) {
  5836. currentFile = state.activeFile;
  5837. } else if (openFiles.size > 0) {
  5838. currentFile = [...openFiles.keys()].pop();
  5839. }
  5840. renderTabs();
  5841. if (currentFile) showTabContent(currentFile);
  5842. }
  5843. // Restore mode (after files are loaded)
  5844. if (state.mode && state.mode !== 'code') {
  5845. switchMode(state.mode);
  5846. }
  5847. // Restore chatWidth and wfBindings from backend (fill localStorage if missing)
  5848. if (state.chatWidth && !localStorage.getItem('vl-chat-width')) {
  5849. localStorage.setItem('vl-chat-width', String(state.chatWidth));
  5850. applyChatWidth(state.chatWidth);
  5851. }
  5852. if (typeof state.showInternalFiles === 'boolean') {
  5853. setInternalFilesVisible(state.showInternalFiles, { reload: true, persist: true });
  5854. }
  5855. if (state.wfBindings && !localStorage.getItem('vl-code-wf-bindings')) {
  5856. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(state.wfBindings));
  5857. }
  5858. setStatus('Workspace restored', 'green');
  5859. setTimeout(() => setStatus('Ready', 'green'), 2000);
  5860. return true;
  5861. } catch {
  5862. return false;
  5863. }
  5864. }
  5865. // ===================== UTILITIES =====================
  5866. async function api(url, chatId) {
  5867. // Append chatId for session-specific GET endpoints
  5868. const u = chatId !== undefined ? `${url}${url.includes('?') ? '&' : '?'}chatId=${chatId}` : url;
  5869. return (await fetch(u)).json();
  5870. }
  5871. async function updateContext() {
  5872. try {
  5873. const ctx = await api('/api/context', activeConvId);
  5874. const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
  5875. $('ctxLabel').textContent = `${pct}%`;
  5876. $('ctxBar').style.width = pct + '%';
  5877. $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
  5878. // Token detail tooltip
  5879. let detail = `Context: ${(ctx.usedTokens/1000).toFixed(1)}K / ${(ctx.maxTokens/1000).toFixed(0)}K`;
  5880. detail += `\nMessages: ${ctx.messageCount} (${ctx.turnCount} turns)`;
  5881. if (ctx.inputTokens !== undefined) {
  5882. detail += `\nInput: ${(ctx.inputTokens/1000).toFixed(1)}K tokens`;
  5883. detail += `\nOutput: ${(ctx.outputTokens/1000).toFixed(1)}K tokens`;
  5884. if (ctx.cacheRead) detail += `\nCache hit: ${(ctx.cacheRead/1000).toFixed(1)}K`;
  5885. detail += `\nSession: ${(ctx.totalInputTokens/1000).toFixed(1)}K in / ${(ctx.totalOutputTokens/1000).toFixed(1)}K out`;
  5886. // Daily token tracking
  5887. const dailyStats = trackDailyTokens(ctx.totalInputTokens, ctx.totalOutputTokens);
  5888. const dayTotal = ((dailyStats.input + dailyStats.output) / 1000).toFixed(1);
  5889. detail += `\n── Today ──`;
  5890. detail += `\nToday: ${(dailyStats.input/1000).toFixed(1)}K in / ${(dailyStats.output/1000).toFixed(1)}K out (${dayTotal}K total)`;
  5891. }
  5892. $('ctxDetail').textContent = detail;
  5893. } catch {}
  5894. }
  5895. /** Track daily token usage in localStorage */
  5896. let _dailyTokenState = null;
  5897. function trackDailyTokens(sessionIn, sessionOut) {
  5898. const today = new Date().toISOString().slice(0, 10);
  5899. if (!_dailyTokenState) {
  5900. try {
  5901. _dailyTokenState = JSON.parse(localStorage.getItem('vl-daily-tokens') || '{}');
  5902. } catch { _dailyTokenState = {}; }
  5903. }
  5904. if (_dailyTokenState.date !== today) {
  5905. _dailyTokenState = { date: today, input: 0, output: 0, lastSessionIn: sessionIn, lastSessionOut: sessionOut };
  5906. }
  5907. const deltaIn = Math.max(0, sessionIn - (_dailyTokenState.lastSessionIn || 0));
  5908. const deltaOut = Math.max(0, sessionOut - (_dailyTokenState.lastSessionOut || 0));
  5909. _dailyTokenState.input += deltaIn;
  5910. _dailyTokenState.output += deltaOut;
  5911. _dailyTokenState.lastSessionIn = sessionIn;
  5912. _dailyTokenState.lastSessionOut = sessionOut;
  5913. try { localStorage.setItem('vl-daily-tokens', JSON.stringify(_dailyTokenState)); } catch {}
  5914. return { input: _dailyTokenState.input, output: _dailyTokenState.output };
  5915. }
  5916. function setStatus(text, color) {
  5917. $('statusText').textContent = text;
  5918. const dot = document.querySelector('.bottom-bar .dot');
  5919. if (dot) dot.className = 'dot dot-' + color;
  5920. }
  5921. // ─── Tab Activity System ─────────────────────────────────────────────────
  5922. // Tab title management (simplified — no Dragon identity)
  5923. let _tabWorkspaceName = '';
  5924. let _tabStatus = 'idle';
  5925. let _tabFlashTimer = null;
  5926. let _tabFlashState = false;
  5927. let _tabHasFocus = true;
  5928. document.addEventListener('visibilitychange', () => {
  5929. _tabHasFocus = !document.hidden;
  5930. if (_tabHasFocus && _tabStatus === 'newOutput') {
  5931. setTabStatus('idle');
  5932. }
  5933. });
  5934. function setTabStatus(status) {
  5935. _tabStatus = status;
  5936. if (_tabFlashTimer) { clearInterval(_tabFlashTimer); _tabFlashTimer = null; }
  5937. const ws = _tabWorkspaceName;
  5938. const name = ws ? `VLCode Lite — ${ws}` : 'VLCode Lite';
  5939. if (status === 'idle') {
  5940. document.title = name;
  5941. } else if (status === 'busy') {
  5942. document.title = `⚡ ${name} — working...`;
  5943. _tabFlashState = false;
  5944. _tabFlashTimer = setInterval(() => {
  5945. _tabFlashState = !_tabFlashState;
  5946. document.title = _tabFlashState ? `⚡ ${name} — working...` : name;
  5947. }, 1200);
  5948. } else if (status === 'newOutput') {
  5949. document.title = `💬 ${name} ✦ NEW`;
  5950. _tabFlashState = false;
  5951. _tabFlashTimer = setInterval(() => {
  5952. _tabFlashState = !_tabFlashState;
  5953. document.title = _tabFlashState ? `💬 ${name} ✦ NEW` : name;
  5954. }, 1500);
  5955. }
  5956. }
  5957. let _sseSource = null;
  5958. function connectSSE() {
  5959. if (_sseSource) { try { _sseSource.close(); } catch {} }
  5960. const es = new EventSource('/api/events');
  5961. _sseSource = es;
  5962. es.onmessage = (e) => {
  5963. try {
  5964. const data = JSON.parse(e.data);
  5965. if (data.type === 'file_changed') {
  5966. setStatus(`Changed: ${data.path}`, 'yellow');
  5967. setTimeout(() => setStatus('Ready', 'green'), 2000);
  5968. // Debounced file tree refresh — show files appearing in real-time
  5969. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  5970. window._fileTreeRefreshTimer = setTimeout(() => {
  5971. loadFileTree();
  5972. window._fileTreeRefreshTimer = null;
  5973. }, 800);
  5974. }
  5975. if (data.type === 'validation_result') {
  5976. setStatus(data.result?.substring(0, 60) || 'Validated', data.result?.includes('Error') ? 'red' : 'green');
  5977. }
  5978. if (data.type === 'file_tree_updated') {
  5979. // File tree data is ready on server — refresh immediately (no debounce needed)
  5980. if (window._fileTreeRefreshTimer) { clearTimeout(window._fileTreeRefreshTimer); window._fileTreeRefreshTimer = null; }
  5981. loadFileTree();
  5982. }
  5983. if (data.type === 'project_reloaded') {
  5984. loadFileTree();
  5985. loadProjectInfo();
  5986. }
  5987. if (data.type === 'conversations_cleared') {
  5988. // Server cleared all sessions — reset frontend to match
  5989. localStorage.removeItem(chatStorageKey());
  5990. resetConversationState();
  5991. saveChatState();
  5992. }
  5993. if (data.type === 'workspace_switched') {
  5994. $('wsPopover')?.classList.remove('open');
  5995. // Update currentWorkDir immediately so loadWorkspaces() marks the right entry as active
  5996. currentWorkDir = data.workDir || '';
  5997. loadFileTree();
  5998. loadProjectInfo();
  5999. previewUrls = {};
  6000. $('previewUrlsPanel').style.display = 'none';
  6001. $('previewUrlsList').innerHTML = '';
  6002. $('previewUrlLabel').textContent = '';
  6003. if ($('cloudGid')) $('cloudGid').value = '';
  6004. if (currentWorkDir) {
  6005. Promise.all([
  6006. loadPreviewUrlsFromProfile(),
  6007. loadCloudGid(),
  6008. ]).catch(() => {});
  6009. }
  6010. renderWsTabs(); // Refresh tabs directly in case loadWorkspaces fails
  6011. loadWorkspaces();
  6012. // Reload chat state (conversations/tabs) from new workspace
  6013. if (currentWorkDir) fetchChatStateFromServer();
  6014. else resetConversationState();
  6015. // Refresh Map if currently visible — metadata from old workspace is stale
  6016. if (currentMode === 'meta') {
  6017. switchMode('meta'); // re-triggers metadata load from new workspace
  6018. }
  6019. }
  6020. if (data.type === 'settings_changed') {
  6021. loadProjectInfo();
  6022. }
  6023. if (data.type === 'cloud_status') {
  6024. if (data.status === 'connected') showCloudConnected(data.user);
  6025. else showCloudDisconnected();
  6026. }
  6027. if (data.type === 'compile_done') {
  6028. if (data.gid && $('cloudGid')) $('cloudGid').value = String(data.gid);
  6029. if (data.previewUrls && Object.keys(data.previewUrls).length > 0) {
  6030. activatePreview(data.previewUrls);
  6031. } else if (currentWorkDir) {
  6032. loadPreviewUrlsFromProfile();
  6033. }
  6034. }
  6035. if (data.type === 'cloud_sync') {
  6036. if (data.status === 'pushing' || data.status === 'pulling') {
  6037. showCloudSyncStatus(data.status === 'pushing' ? 'Pushing...' : 'Pulling...', 'syncing');
  6038. } else if (data.status === 'pushed') {
  6039. showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
  6040. } else if (data.status === 'pulled') {
  6041. showCloudSyncStatus(`Pulled ${data.fileCount} files`, 'ok');
  6042. } else if (data.status === 'error') {
  6043. showCloudSyncStatus('Error: ' + data.message, 'error');
  6044. }
  6045. }
  6046. if (data.type === 'server_restart') {
  6047. setStatus('Server restarting...', 'yellow');
  6048. // Wait for server to come back, then reload
  6049. setTimeout(() => waitForServerAndReload(), data.delay || 2000);
  6050. }
  6051. // Workflow trigger (local — approveAndRunWorkflow)
  6052. if (data.type === 'run_workflow') {
  6053. const wfName = data.payload?.workflowName;
  6054. if (wfName) {
  6055. addMsg('assistant', `**Workflow trigger:** ${wfName}`);
  6056. const fakeBtn = document.createElement('button');
  6057. const fakeDiv = document.createElement('div');
  6058. fakeDiv.className = 'wf-progress-actions';
  6059. fakeDiv.appendChild(fakeBtn);
  6060. const wrapper = document.createElement('div');
  6061. wrapper.className = 'wf-progress';
  6062. wrapper.appendChild(fakeDiv);
  6063. $('chatMessages').appendChild(wrapper);
  6064. approveAndRunWorkflow(wfName, fakeBtn);
  6065. }
  6066. }
  6067. // ── Multi-window sync events ──
  6068. if (data.type === 'ws_tabs_changed') {
  6069. renderWsTabs(); // refresh from /api/windows
  6070. }
  6071. // Codegen workflow selector changed in another window
  6072. if (data.type === 'wf_selection_changed' && data.workflow) {
  6073. if (data.workflow !== _selectedCodegenWorkflow) {
  6074. _selectedCodegenWorkflow = data.workflow;
  6075. if (CODEGEN_WORKFLOWS[data.workflow]) {
  6076. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.workflow].label;
  6077. workflowBindings.generate = CODEGEN_WORKFLOWS[data.workflow].file;
  6078. }
  6079. }
  6080. }
  6081. // Flow tab workflow dropdown changed in another window
  6082. if (data.type === 'ui_state_changed' || data.type === 'ui_state') {
  6083. if (data.flowWorkflow) {
  6084. _setFlowWfSelectOrStore(data.flowWorkflow, $('flowWfSelect'));
  6085. }
  6086. }
  6087. // Workflow run state — sent on SSE connect for windows opening mid-run
  6088. if (data.type === 'current_run_state' && data.active && data.workflowName) {
  6089. // Restore running workflow in Flow tab (already handled by wf_start broadcasts during run)
  6090. _workflowActive = true;
  6091. _lastWorkflowName = data.workflowName;
  6092. window._skipFlowAutoLoad = true;
  6093. loadWorkflowIntoFlowTab(data.workflowName);
  6094. }
  6095. // Chat state changed in another window — sync messages
  6096. if (data.type === 'chat_state_changed') {
  6097. const convId = data.chatId;
  6098. const conv = conversations.find(c => c.id === convId);
  6099. if (conv && data.messageCount > 0 && !_currentAbortController) {
  6100. const domCount = ($('chatMessages')?.querySelectorAll('.msg').length) || 0;
  6101. if (convId === activeConvId && data.messageCount > domCount) {
  6102. _rebuildChatDom(convId);
  6103. } else if (convId !== activeConvId) {
  6104. conv.dom = '';
  6105. conv.messageCount = data.messageCount;
  6106. }
  6107. }
  6108. }
  6109. } catch {}
  6110. };
  6111. // Auto-reconnect on disconnect
  6112. es.onerror = () => {
  6113. es.close();
  6114. _sseSource = null;
  6115. setStatus('Disconnected — reconnecting...', 'red');
  6116. setTimeout(() => {
  6117. fetch('/api/version').then(r => r.json()).then(() => {
  6118. setStatus('Reconnected', 'green');
  6119. connectSSE();
  6120. loadProjectInfo();
  6121. loadFileTree();
  6122. }).catch(() => setTimeout(connectSSE, 3000));
  6123. }, 2000);
  6124. };
  6125. }
  6126. // ═══════ Server Health Detection (P2) ═══════
  6127. let _healthFails = 0;
  6128. let _disconnectOverlay = null;
  6129. setInterval(async () => {
  6130. try {
  6131. const r = await fetch('/api/health', { signal: AbortSignal.timeout(3000) });
  6132. if (r.ok) {
  6133. _healthFails = 0;
  6134. if (_disconnectOverlay) { _disconnectOverlay.remove(); _disconnectOverlay = null; }
  6135. } else _healthFails++;
  6136. } catch { _healthFails++; }
  6137. if (_healthFails >= 3 && !_disconnectOverlay) {
  6138. _disconnectOverlay = document.createElement('div');
  6139. _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;';
  6140. _disconnectOverlay.textContent = '⚠ Server disconnected — reconnecting...';
  6141. document.body.appendChild(_disconnectOverlay);
  6142. }
  6143. }, 10000);
  6144. /** Wait for server to come back after restart, then soft-reload */
  6145. function waitForServerAndReload() {
  6146. let attempts = 0;
  6147. const check = () => {
  6148. fetch('/api/version').then(r => r.json()).then(data => {
  6149. setStatus(`Server v${data.version} ready`, 'green');
  6150. // Soft reload: reconnect SSE, reload data, keep UI state
  6151. connectSSE();
  6152. loadProjectInfo();
  6153. loadFileTree();
  6154. loadWorkspaces();
  6155. updateContext();
  6156. }).catch(() => {
  6157. if (++attempts < 20) setTimeout(check, 1000);
  6158. else setStatus('Server not responding — please reload page', 'red');
  6159. });
  6160. };
  6161. setTimeout(check, 1000);
  6162. }
  6163. /** Show the update button in header */
  6164. function showUpdateButton() {
  6165. const btn = $('updateBtn');
  6166. if (btn) btn.style.display = '';
  6167. }
  6168. function escapeHtml(s) {
  6169. if (!s) return '';
  6170. return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  6171. }
  6172. /** Simple markdown → HTML renderer */
  6173. function renderMarkdown(text) {
  6174. if (!text) return '';
  6175. let html = escapeHtml(text);
  6176. // Code blocks (``` ... ```)
  6177. html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
  6178. `<pre><code class="lang-${lang}">${code.trim()}</code></pre>`);
  6179. // Inline code
  6180. html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
  6181. // Headers
  6182. html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
  6183. html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
  6184. html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
  6185. // Bold + italic
  6186. html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
  6187. html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  6188. html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
  6189. // Blockquote
  6190. html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
  6191. // Unordered list
  6192. html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
  6193. html = html.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
  6194. // Ordered list
  6195. html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
  6196. // Links (markdown format)
  6197. html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
  6198. // Auto-link bare URLs (not already inside href or tag)
  6199. html = html.replace(/(^|[^"=>])(https?:\/\/[^\s<)"']+)/g, '$1<a href="$2" target="_blank">$2</a>');
  6200. // Paragraphs (double newline)
  6201. html = html.replace(/\n\n/g, '</p><p>');
  6202. html = `<p>${html}</p>`;
  6203. html = html.replace(/<p><(h[123]|pre|ul|ol|blockquote)/g, '<$1');
  6204. html = html.replace(/<\/(h[123]|pre|ul|ol|blockquote)><\/p>/g, '</$1>');
  6205. // Single newlines → <br> (but not inside pre)
  6206. html = html.replace(/<p>([\s\S]*?)<\/p>/g, (_, inner) =>
  6207. `<p>${inner.replace(/\n/g, '<br>')}</p>`);
  6208. return html;
  6209. }
  6210. // ===================== IMAGE UPLOAD =====================
  6211. function setupImagePaste() {
  6212. // Paste images from clipboard
  6213. document.addEventListener('paste', e => {
  6214. const items = e.clipboardData?.items;
  6215. if (!items) return;
  6216. for (const item of items) {
  6217. if (item.type.startsWith('image/')) {
  6218. e.preventDefault();
  6219. const file = item.getAsFile();
  6220. addImageAttachment(file);
  6221. }
  6222. }
  6223. });
  6224. // File input change
  6225. $('imageInput').addEventListener('change', e => {
  6226. for (const file of e.target.files) addImageAttachment(file);
  6227. e.target.value = '';
  6228. });
  6229. // Drag image onto chat input area
  6230. const inputArea = document.querySelector('.chat-input-area');
  6231. inputArea.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
  6232. inputArea.addEventListener('drop', e => {
  6233. e.preventDefault();
  6234. for (const file of e.dataTransfer.files) {
  6235. if (file.type.startsWith('image/')) addImageAttachment(file);
  6236. }
  6237. });
  6238. }
  6239. function addImageAttachment(file) {
  6240. const reader = new FileReader();
  6241. reader.onload = () => {
  6242. const dataUrl = reader.result;
  6243. const base64 = dataUrl.split(',')[1];
  6244. const mediaType = file.type;
  6245. pendingImages.push({ data: base64, mediaType, preview: dataUrl });
  6246. renderAttachments();
  6247. };
  6248. reader.readAsDataURL(file);
  6249. }
  6250. function renderAttachments() {
  6251. const container = $('chatAttachments');
  6252. container.innerHTML = '';
  6253. for (let i = 0; i < pendingImages.length; i++) {
  6254. const div = document.createElement('div');
  6255. div.className = 'chat-attach-item';
  6256. div.innerHTML = `<img src="${pendingImages[i].preview}"><span class="remove" onclick="removeImage(${i})">&times;</span>`;
  6257. container.appendChild(div);
  6258. }
  6259. for (let i = 0; i < pendingMentions.length; i++) {
  6260. const div = document.createElement('div');
  6261. div.className = 'chat-attach-item';
  6262. div.innerHTML = `@${escapeHtml(pendingMentions[i])}<span class="remove" onclick="removeMention(${i})">&times;</span>`;
  6263. container.appendChild(div);
  6264. }
  6265. }
  6266. function removeImage(idx) { pendingImages.splice(idx, 1); renderAttachments(); }
  6267. function removeMention(idx) { pendingMentions.splice(idx, 1); renderAttachments(); }
  6268. function autoResizeChatInput(reset = false) {
  6269. const input = $('chatInput');
  6270. if (!input) return;
  6271. if (reset) input.style.height = '';
  6272. input.style.height = 'auto';
  6273. input.style.height = Math.min(input.scrollHeight, 180) + 'px';
  6274. }
  6275. // ===================== @-MENTION AUTOCOMPLETE =====================
  6276. $('chatInput').addEventListener('input', async function(e) {
  6277. autoResizeChatInput();
  6278. const val = this.value;
  6279. const atIdx = val.lastIndexOf('@');
  6280. if (atIdx === -1 || atIdx < val.lastIndexOf(' ', this.selectionStart - 1)) {
  6281. $('mentionDropdown').classList.remove('open');
  6282. return;
  6283. }
  6284. const query = val.slice(atIdx + 1, this.selectionStart).toLowerCase();
  6285. if (query.length === 0 && val[atIdx - 1] && val[atIdx - 1] !== ' ') {
  6286. $('mentionDropdown').classList.remove('open');
  6287. return;
  6288. }
  6289. // Fetch matching files
  6290. try {
  6291. const files = await api(`/api/files/autocomplete?q=${encodeURIComponent(query)}`);
  6292. if (files.length === 0) { $('mentionDropdown').classList.remove('open'); return; }
  6293. const dd = $('mentionDropdown');
  6294. dd.innerHTML = '';
  6295. mentionIdx = -1;
  6296. for (const f of files) {
  6297. const item = document.createElement('div');
  6298. item.className = 'mention-item';
  6299. const typeClass = 'type-' + getType(f.name);
  6300. item.innerHTML = `<span class="m-type ${typeClass}">${f.type || getType(f.name)}</span>${escapeHtml(f.name)}`;
  6301. item.onclick = () => selectMention(f.name, atIdx);
  6302. dd.appendChild(item);
  6303. }
  6304. dd.classList.add('open');
  6305. } catch {}
  6306. });
  6307. $('chatInput').addEventListener('keydown', function(e) {
  6308. const dd = $('mentionDropdown');
  6309. if (!dd.classList.contains('open')) {
  6310. if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
  6311. return;
  6312. }
  6313. const items = dd.querySelectorAll('.mention-item');
  6314. if (e.key === 'ArrowDown') { e.preventDefault(); mentionIdx = Math.min(mentionIdx + 1, items.length - 1); highlightMention(items); }
  6315. else if (e.key === 'ArrowUp') { e.preventDefault(); mentionIdx = Math.max(mentionIdx - 1, 0); highlightMention(items); }
  6316. else if (e.key === 'Enter' || e.key === 'Tab') {
  6317. e.preventDefault();
  6318. if (mentionIdx >= 0 && items[mentionIdx]) {
  6319. const name = items[mentionIdx].textContent.trim();
  6320. const atIdx = this.value.lastIndexOf('@');
  6321. selectMention(name, atIdx);
  6322. }
  6323. } else if (e.key === 'Escape') {
  6324. dd.classList.remove('open');
  6325. }
  6326. });
  6327. function highlightMention(items) {
  6328. items.forEach((el, i) => el.classList.toggle('selected', i === mentionIdx));
  6329. }
  6330. function selectMention(name, atIdx) {
  6331. const input = $('chatInput');
  6332. input.value = input.value.slice(0, atIdx) + '@' + name + ' ' + input.value.slice(input.selectionStart);
  6333. input.focus();
  6334. $('mentionDropdown').classList.remove('open');
  6335. if (!pendingMentions.includes(name)) {
  6336. pendingMentions.push(name);
  6337. renderAttachments();
  6338. }
  6339. }
  6340. // ===================== CONVERSATION TABS =====================
  6341. function renderConvTabs() {
  6342. const tabs = $('convTabs');
  6343. // Preserve history panel if open
  6344. const existingPanel = tabs.querySelector('.history-panel');
  6345. const panelWasOpen = existingPanel?.classList.contains('open');
  6346. tabs.innerHTML = '';
  6347. for (const conv of conversations) {
  6348. const tab = document.createElement('div');
  6349. tab.className = 'conv-tab' + (conv.id === activeConvId ? ' active' : '');
  6350. tab.dataset.conv = conv.id;
  6351. tab.innerHTML = `${escapeHtml(conv.name)}${conversations.length > 1 ? '<span class="conv-close" onclick="event.stopPropagation();closeConversation(' + conv.id + ')">&times;</span>' : ''}`;
  6352. tab.onclick = () => switchConversation(conv.id);
  6353. tabs.appendChild(tab);
  6354. }
  6355. const addBtn = document.createElement('button');
  6356. addBtn.className = 'conv-new';
  6357. addBtn.textContent = '+';
  6358. addBtn.onclick = newConversation;
  6359. addBtn.title = 'New conversation';
  6360. tabs.appendChild(addBtn);
  6361. // Spacer pushes history button to the right
  6362. const spacer = document.createElement('div');
  6363. spacer.className = 'tab-spacer';
  6364. tabs.appendChild(spacer);
  6365. // History button (right-aligned)
  6366. const histBtn = document.createElement('button');
  6367. histBtn.className = 'conv-history-btn';
  6368. histBtn.innerHTML = 'History';
  6369. histBtn.title = 'Chat history';
  6370. histBtn.onclick = (e) => { e.stopPropagation(); toggleHistoryPanel(); };
  6371. tabs.appendChild(histBtn);
  6372. // History dropdown panel
  6373. const panel = document.createElement('div');
  6374. panel.className = 'history-panel';
  6375. panel.id = 'historyPanel';
  6376. 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>`;
  6377. panel.onclick = (e) => e.stopPropagation();
  6378. tabs.appendChild(panel);
  6379. if (panelWasOpen) { panel.classList.add('open'); loadHistoryItems(); }
  6380. }
  6381. async function newConversation() {
  6382. // Save current chat DOM
  6383. const curConv = conversations.find(c => c.id === activeConvId);
  6384. if (curConv) curConv.dom = $('chatMessages').innerHTML;
  6385. // Create on backend first (source of truth)
  6386. try {
  6387. const res = await fetch('/api/conversations', {
  6388. method: 'POST',
  6389. headers: { 'Content-Type': 'application/json' },
  6390. body: JSON.stringify({}),
  6391. });
  6392. const data = await res.json();
  6393. const id = data.id;
  6394. const name = data.name;
  6395. conversations.push({ id, name, messages: [], dom: '' });
  6396. if (id >= convIdCounter) convIdCounter = id + 1;
  6397. activeConvId = id;
  6398. } catch {
  6399. // Fallback: local creation
  6400. const id = convIdCounter++;
  6401. conversations.push({ id, name: `Chat ${id + 1}`, messages: [], dom: '' });
  6402. activeConvId = id;
  6403. }
  6404. $('chatMessages').innerHTML = '';
  6405. renderConvTabs();
  6406. saveChatState();
  6407. }
  6408. async function switchConversation(id) {
  6409. if (id === activeConvId) return;
  6410. const curConv = conversations.find(c => c.id === activeConvId);
  6411. if (curConv) curConv.dom = $('chatMessages').innerHTML;
  6412. activeConvId = id;
  6413. const target = conversations.find(c => c.id === id);
  6414. if (target?.dom) {
  6415. // Check if DOM is stale vs server message count
  6416. const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
  6417. if (domMsgCount < (target.messageCount || 0)) {
  6418. $('chatMessages').innerHTML = '';
  6419. await _rebuildChatDom(id);
  6420. } else {
  6421. $('chatMessages').innerHTML = target.dom;
  6422. }
  6423. } else {
  6424. $('chatMessages').innerHTML = '';
  6425. // Try to rebuild from server messages when dom is empty
  6426. await _rebuildChatDom(id);
  6427. }
  6428. renderConvTabs();
  6429. // Push active tab change to backend
  6430. pushChatStateToServer();
  6431. }
  6432. async function closeConversation(id) {
  6433. if (conversations.length <= 1) return;
  6434. const conv = conversations.find(c => c.id === id);
  6435. // If conversation has content, AI summarize & archive
  6436. if (conv) {
  6437. const dom = (id === activeConvId) ? ($('chatMessages')?.innerHTML || '') : (conv.dom || '');
  6438. if (dom && dom.length > 50) {
  6439. const tmp = document.createElement('div');
  6440. tmp.innerHTML = dom;
  6441. const msgs = [];
  6442. tmp.querySelectorAll('.msg').forEach(el => {
  6443. const role = el.classList.contains('user') ? 'user' : 'assistant';
  6444. const text = el.querySelector('.content-text')?.textContent || '';
  6445. const time = el.dataset.timestamp || '';
  6446. if (text.trim()) msgs.push({ role, text: text.substring(0, 1000), time });
  6447. });
  6448. if (msgs.length > 0) {
  6449. fetch('/api/chat/summarize-and-save', {
  6450. method: 'POST',
  6451. headers: { 'Content-Type': 'application/json' },
  6452. body: JSON.stringify({ convId: id, name: conv.name, messages: msgs }),
  6453. }).catch(() => {});
  6454. }
  6455. }
  6456. }
  6457. // Delete on backend first
  6458. try { await fetch(`/api/conversations/${id}`, { method: 'DELETE' }); } catch {}
  6459. // Update local state
  6460. conversations = conversations.filter(c => c.id !== id);
  6461. if (activeConvId === id) {
  6462. activeConvId = conversations[0].id;
  6463. const target = conversations[0];
  6464. $('chatMessages').innerHTML = target?.dom || '';
  6465. }
  6466. renderConvTabs();
  6467. saveChatState();
  6468. // Immediately sync to backend (don't wait for 10s interval)
  6469. pushChatStateToServer();
  6470. }
  6471. /** Auto-generate a short title for a conversation after its first chat turn */
  6472. async function autoTitleConversation(convId, userMessage) {
  6473. const conv = conversations.find(c => c.id === convId);
  6474. if (!conv) return;
  6475. // Only auto-title if name still matches default pattern (Chat N)
  6476. if (!/^Chat \d+$/.test(conv.name)) return;
  6477. try {
  6478. const res = await fetch('/api/chat/generate-title', {
  6479. method: 'POST',
  6480. headers: { 'Content-Type': 'application/json' },
  6481. body: JSON.stringify({ chatId: convId, userMessage }),
  6482. });
  6483. const data = await res.json();
  6484. if (data.ok && data.title) {
  6485. conv.name = data.title;
  6486. renderConvTabs();
  6487. saveChatState();
  6488. // Sync to backend registry
  6489. fetch(`/api/conversations/${convId}`, {
  6490. method: 'PATCH',
  6491. headers: { 'Content-Type': 'application/json' },
  6492. body: JSON.stringify({ name: data.title }),
  6493. }).catch(() => {});
  6494. }
  6495. } catch {}
  6496. }
  6497. /** Save current conversation to server (persistent across browser refreshes) */
  6498. async function saveConversationToServer(title) {
  6499. try {
  6500. const convTitle = title || `Chat ${activeConvId + 1} — ${new Date().toLocaleString()}`;
  6501. const res = await fetch('/api/conversation/save', {
  6502. method: 'POST',
  6503. headers: { 'Content-Type': 'application/json' },
  6504. body: JSON.stringify({ id: `conv_${activeConvId}`, title: convTitle, chatId: activeConvId }),
  6505. });
  6506. const data = await res.json();
  6507. if (data.ok) setStatus(`Saved: ${convTitle}`, 'green');
  6508. else setStatus(data.error || 'Save failed', 'red');
  6509. } catch (e) { setStatus('Save error: ' + e.message, 'red'); }
  6510. }
  6511. /** Restore a conversation from server */
  6512. async function restoreConversationFromServer(convId) {
  6513. try {
  6514. const res = await fetch(`/api/conversation/restore/${convId}`, { method: 'POST' });
  6515. const data = await res.json();
  6516. if (data.ok) {
  6517. $('chatMessages').innerHTML = '';
  6518. addMsg('assistant', `Restored conversation: ${data.conversation.title} (${data.conversation.messageCount} messages in context)`);
  6519. setStatus(`Restored: ${data.conversation.title}`, 'green');
  6520. updateContext();
  6521. } else {
  6522. setStatus(data.error || 'Restore failed', 'red');
  6523. }
  6524. } catch (e) { setStatus('Restore error: ' + e.message, 'red'); }
  6525. }
  6526. // ── History Panel ──
  6527. let _historyCache = null;
  6528. let _historyQuery = '';
  6529. function toggleHistoryPanel() {
  6530. const panel = $('historyPanel');
  6531. if (!panel) return;
  6532. const isOpen = panel.classList.contains('open');
  6533. if (isOpen) {
  6534. panel.classList.remove('open');
  6535. } else {
  6536. panel.classList.add('open');
  6537. loadHistoryItems();
  6538. // Close on outside click
  6539. setTimeout(() => {
  6540. const closer = (e) => {
  6541. if (!panel.contains(e.target) && !e.target.classList.contains('conv-history-btn')) {
  6542. panel.classList.remove('open');
  6543. document.removeEventListener('click', closer);
  6544. }
  6545. };
  6546. document.addEventListener('click', closer);
  6547. }, 0);
  6548. }
  6549. }
  6550. async function loadHistoryItems(query) {
  6551. const list = $('historyList');
  6552. if (!list) return;
  6553. try {
  6554. const q = query !== undefined ? query : _historyQuery;
  6555. const res = await fetch('/api/chat/history' + (q ? '?q=' + encodeURIComponent(q) : ''));
  6556. const data = await res.json();
  6557. _historyCache = data.items || [];
  6558. renderHistoryList(_historyCache);
  6559. } catch (e) {
  6560. list.innerHTML = '<div class="history-empty">Failed to load history</div>';
  6561. }
  6562. }
  6563. function searchHistory(query) {
  6564. _historyQuery = query;
  6565. if (_historyCache && !query) {
  6566. renderHistoryList(_historyCache);
  6567. return;
  6568. }
  6569. // Debounce: load from server with query
  6570. clearTimeout(searchHistory._timer);
  6571. searchHistory._timer = setTimeout(() => loadHistoryItems(query), 200);
  6572. }
  6573. const CATEGORY_COLORS = {
  6574. bug_fix: '#f85149', feature: '#3fb950', refactor: '#a371f7',
  6575. question: '#58a6ff', config: '#d29922', design: '#f778ba', general: '#8b949e', other: '#8b949e',
  6576. };
  6577. function renderHistoryList(items) {
  6578. const list = $('historyList');
  6579. if (!list) return;
  6580. if (!items || items.length === 0) {
  6581. list.innerHTML = '<div class="history-empty">No history found</div>';
  6582. return;
  6583. }
  6584. list.innerHTML = '';
  6585. for (const item of items) {
  6586. const div = document.createElement('div');
  6587. div.className = 'history-item';
  6588. const date = item.archivedAt ? new Date(item.archivedAt) : null;
  6589. const dateStr = date ? formatRelativeDate(date) : '';
  6590. const tagColor = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.general;
  6591. div.innerHTML = `<div class="hi-title">${escapeHtml(item.name)}</div>`
  6592. + `<div class="hi-meta">`
  6593. + `<span class="hi-tag" style="color:${tagColor};border-color:${tagColor}40">${item.category}</span>`
  6594. + `<span>${item.messageCount} msgs</span>`
  6595. + `<span>${dateStr}</span>`
  6596. + `</div>`
  6597. + (item.summary ? `<div class="hi-summary">${escapeHtml(item.summary)}</div>` : '');
  6598. div.onclick = () => restoreHistoryItem(item);
  6599. list.appendChild(div);
  6600. }
  6601. }
  6602. function formatRelativeDate(d) {
  6603. const now = new Date();
  6604. const diff = now - d;
  6605. const mins = Math.floor(diff / 60000);
  6606. if (mins < 1) return 'just now';
  6607. if (mins < 60) return `${mins}m ago`;
  6608. const hours = Math.floor(mins / 60);
  6609. if (hours < 24) return `${hours}h ago`;
  6610. const days = Math.floor(hours / 24);
  6611. if (days < 7) return `${days}d ago`;
  6612. return d.toLocaleDateString();
  6613. }
  6614. async function restoreHistoryItem(item) {
  6615. // Close the panel
  6616. $('historyPanel')?.classList.remove('open');
  6617. // Show summary in chat
  6618. addMsg('assistant', `**Restored from history:** ${item.name}\n\n` +
  6619. (item.summary ? `> ${item.summary}\n\n` : '') +
  6620. (item.userNeeds ? `**User needs:** ${item.userNeeds}\n` : '') +
  6621. `**Category:** ${item.category} | **Messages:** ${item.messageCount}`);
  6622. scrollChat();
  6623. setStatus(`Loaded: ${item.name}`, 'green');
  6624. }
  6625. // ===================== THINKING INDICATOR =====================
  6626. let activeThinkingEl = null;
  6627. function addThinkingIndicator() {
  6628. const container = $('chatMessages');
  6629. const div = document.createElement('div');
  6630. div.className = 'thinking-block';
  6631. div.innerHTML = `
  6632. <div class="thinking-header" onclick="this.nextElementSibling.classList.toggle('open')">
  6633. <span class="think-icon">&#x1F4AD;</span> <span>Thinking...</span>
  6634. </div>
  6635. <div class="thinking-body"></div>`;
  6636. container.appendChild(div);
  6637. activeThinkingEl = div;
  6638. scrollChat();
  6639. }
  6640. function appendThinkingText(text) {
  6641. if (!activeThinkingEl) return;
  6642. activeThinkingEl.querySelector('.thinking-body').textContent += text;
  6643. }
  6644. function finalizeThinking() {
  6645. if (!activeThinkingEl) return;
  6646. activeThinkingEl.classList.add('done');
  6647. const header = activeThinkingEl.querySelector('.thinking-header span:last-child');
  6648. const body = activeThinkingEl.querySelector('.thinking-body');
  6649. const chars = body.textContent.length;
  6650. header.textContent = `Thought for ${chars > 500 ? Math.round(chars / 100) + ' blocks' : chars + ' chars'}`;
  6651. activeThinkingEl = null;
  6652. }
  6653. function addRetryIndicator(attempt, delay, status) {
  6654. const container = $('chatMessages');
  6655. const div = document.createElement('div');
  6656. div.className = 'retry-msg';
  6657. div.innerHTML = `&#9889; Retry ${attempt}/3 (${status}) — waiting ${(delay / 1000).toFixed(1)}s`;
  6658. container.appendChild(div);
  6659. scrollChat();
  6660. }
  6661. // ===================== KEYBOARD SHORTCUTS =====================
  6662. document.addEventListener('keydown', e => {
  6663. // Escape: close any open modal / context menu
  6664. if (e.key === 'Escape') {
  6665. $('settingsModal').classList.remove('open');
  6666. $('genModal').classList.remove('open');
  6667. $('fileCtxMenu').classList.remove('open');
  6668. closeChatMoreMenu();
  6669. }
  6670. // Cmd/Ctrl+K: clear chat
  6671. if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
  6672. e.preventDefault();
  6673. $('chatMessages').innerHTML = '';
  6674. }
  6675. // Cmd/Ctrl+/: focus chat input
  6676. if ((e.metaKey || e.ctrlKey) && e.key === '/') {
  6677. e.preventDefault();
  6678. $('chatInput').focus();
  6679. }
  6680. });
  6681. // ===================== LOAD FOLDER =====================
  6682. async function loadFolder() {
  6683. if (window.showDirectoryPicker) {
  6684. try {
  6685. const dirHandle = await window.showDirectoryPicker();
  6686. setStatus('Reading folder...', 'yellow');
  6687. const files = await readDirHandle(dirHandle, '');
  6688. await uploadFiles(files);
  6689. return;
  6690. } catch (e) { if (e.name === 'AbortError') return; }
  6691. }
  6692. $('folderInput').click();
  6693. }
  6694. $('folderInput').addEventListener('change', async (e) => {
  6695. if (!e.target.files?.length) return;
  6696. setStatus('Reading folder...', 'yellow');
  6697. await uploadFiles(await readFileList(e.target.files));
  6698. e.target.value = '';
  6699. });
  6700. async function readDirHandle(dirHandle, prefix) {
  6701. 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'];
  6702. const files = [];
  6703. for await (const entry of dirHandle.values()) {
  6704. const ep = prefix ? `${prefix}/${entry.name}` : entry.name;
  6705. if (entry.kind === 'directory') {
  6706. if (entry.name === 'node_modules' || entry.name === '.git') continue; // skip heavy dirs
  6707. files.push(...await readDirHandle(entry, ep));
  6708. } else if (entry.kind === 'file') {
  6709. const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop().toLowerCase() : '';
  6710. if (!ext || codeExts.includes(ext)) {
  6711. files.push({ path: ep, content: await (await entry.getFile()).text() });
  6712. }
  6713. }
  6714. }
  6715. return files;
  6716. }
  6717. async function readFileList(fileList) {
  6718. 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'];
  6719. const files = [];
  6720. for (const file of fileList) {
  6721. const ext = file.name.includes('.') ? '.' + file.name.split('.').pop().toLowerCase() : '';
  6722. if (ext && !codeExts.includes(ext)) continue;
  6723. const parts = (file.webkitRelativePath || file.name).split('/');
  6724. files.push({ path: parts.length > 1 ? parts.slice(1).join('/') : parts[0], content: await file.text() });
  6725. }
  6726. return files;
  6727. }
  6728. async function uploadFiles(files) {
  6729. if (!files?.length) { setStatus('No files found to import', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
  6730. setStatus(`Uploading ${files.length} files...`, 'yellow');
  6731. try {
  6732. await ensureWorkspaceForImport('ImportedFolder');
  6733. const res = await fetch('/api/upload-folder', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({files}) });
  6734. const data = await res.json();
  6735. if (data.error) { setStatus(data.error, 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
  6736. setStatus(`Imported ${data.filesWritten} files`, 'green');
  6737. await loadFileTree();
  6738. await loadProjectInfo();
  6739. autoOpenFirstFile();
  6740. } catch (e) { setStatus('Upload failed', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); }
  6741. }
  6742. /** Auto-open the first VL file (.vx preferred) after loading a project */
  6743. async function autoOpenFirstFile() {
  6744. try {
  6745. const data = await api('/api/files');
  6746. const priority = ['vx','sc','cp','vs','vdb','vth'];
  6747. for (const ext of priority) {
  6748. const found = data.files.find(f => f.endsWith('.' + ext));
  6749. if (found) { openFile(found); return; }
  6750. }
  6751. if (data.files.length > 0) openFile(data.files[0]);
  6752. } catch {}
  6753. }
  6754. // ===================== FILE MANAGEMENT (Delete / Clear / ZIP) =====================
  6755. async function deleteFile(fpath) {
  6756. if (!fpath) return;
  6757. try {
  6758. await fetch(`/api/file?path=${encodeURIComponent(fpath)}`, { method: 'DELETE' });
  6759. // Close tab if open
  6760. if (openFiles.has(fpath)) closeTab(fpath);
  6761. await loadFileTree();
  6762. setStatus('Deleted ' + fpath.split('/').pop(), 'green');
  6763. } catch (e) { setStatus('Delete failed', 'red'); }
  6764. }
  6765. async function clearAllFiles() {
  6766. if (!confirm('Delete ALL VL files in this project? This cannot be undone.')) return;
  6767. try {
  6768. await fetch('/api/files/clear', { method: 'POST' });
  6769. openFiles.clear();
  6770. currentFile = null;
  6771. renderTabs();
  6772. $('editor').style.display = 'none';
  6773. $('iframeContainer').style.display = 'none';
  6774. $('editorPlaceholder').style.display = 'block';
  6775. await loadFileTree();
  6776. await loadProjectInfo();
  6777. setStatus('All files cleared', 'green');
  6778. } catch (e) { setStatus('Clear failed', 'red'); }
  6779. }
  6780. /** Initialize VL project structure with directories and core files */
  6781. async function initProject() {
  6782. setStatus('Initializing project...', 'yellow');
  6783. try {
  6784. await fetch('/api/project/init', { method: 'POST' });
  6785. await loadFileTree();
  6786. await loadProjectInfo();
  6787. setStatus('Project initialized', 'green');
  6788. } catch (e) { setStatus('Init failed', 'red'); }
  6789. }
  6790. function importZip() { $('zipInput').click(); }
  6791. $('zipInput').addEventListener('change', async (e) => {
  6792. const file = e.target.files[0];
  6793. if (!file) return;
  6794. setStatus('Importing ZIP...', 'yellow');
  6795. try {
  6796. const formData = new FormData();
  6797. formData.append('zip', file);
  6798. // Read as base64 and send as JSON since our server uses JSON
  6799. const reader = new FileReader();
  6800. reader.onload = async () => {
  6801. await ensureWorkspaceForImport(file.name);
  6802. const base64 = reader.result.split(',')[1];
  6803. const res = await fetch('/api/upload-zip', {
  6804. method: 'POST', headers: { 'Content-Type': 'application/json' },
  6805. body: JSON.stringify({ data: base64, filename: file.name })
  6806. });
  6807. const data = await res.json();
  6808. if (data.error) { setStatus(data.error, 'red'); return; }
  6809. setStatus(`Imported ZIP ${file.name}`, 'green');
  6810. await loadFileTree();
  6811. await loadProjectInfo();
  6812. // Auto-switch to Code tab and open first file
  6813. autoOpenFirstFile();
  6814. };
  6815. reader.readAsDataURL(file);
  6816. } catch (e) { setStatus('ZIP import failed', 'red'); }
  6817. e.target.value = '';
  6818. });
  6819. // File tree context menu
  6820. function showFileCtxMenu(e, fpath) {
  6821. e.preventDefault();
  6822. e.stopPropagation();
  6823. ctxMenuTarget = fpath;
  6824. const menu = $('fileCtxMenu');
  6825. menu.style.left = e.clientX + 'px';
  6826. menu.style.top = e.clientY + 'px';
  6827. menu.classList.add('open');
  6828. }
  6829. function ctxOpenFile() {
  6830. $('fileCtxMenu').classList.remove('open');
  6831. if (ctxMenuTarget) openFile(ctxMenuTarget);
  6832. }
  6833. function ctxDeleteFile() {
  6834. $('fileCtxMenu').classList.remove('open');
  6835. if (ctxMenuTarget && confirm(`Delete ${ctxMenuTarget}?`)) deleteFile(ctxMenuTarget);
  6836. }
  6837. // Close context menu on click elsewhere
  6838. document.addEventListener('click', () => $('fileCtxMenu').classList.remove('open'));
  6839. // ===================== DRAG-AND-DROP =====================
  6840. let dragCounter = 0;
  6841. document.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; $('dropOverlay').classList.add('active'); });
  6842. document.addEventListener('dragleave', e => { e.preventDefault(); if (--dragCounter <= 0) { dragCounter = 0; $('dropOverlay').classList.remove('active'); } });
  6843. document.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
  6844. document.addEventListener('drop', async (e) => {
  6845. e.preventDefault(); dragCounter = 0; $('dropOverlay').classList.remove('active');
  6846. // Check if drop is on chat input area (handled separately for images)
  6847. if (e.target.closest('.chat-input-area')) return;
  6848. const items = e.dataTransfer.items;
  6849. const dtFiles = e.dataTransfer.files;
  6850. if (!items?.length && !dtFiles?.length) return;
  6851. setStatus('Reading dropped files...', 'yellow');
  6852. // Handle ZIP files dropped directly
  6853. for (const f of dtFiles) {
  6854. if (f.name.endsWith('.zip')) {
  6855. const reader = new FileReader();
  6856. reader.onload = async () => {
  6857. await ensureWorkspaceForImport(f.name);
  6858. const base64 = reader.result.split(',')[1];
  6859. try {
  6860. const res = await fetch('/api/upload-zip', {
  6861. method: 'POST', headers: { 'Content-Type': 'application/json' },
  6862. body: JSON.stringify({ data: base64, filename: f.name })
  6863. });
  6864. const data = await res.json();
  6865. if (data.error) { setStatus(data.error, 'red'); return; }
  6866. setStatus(`Imported ZIP ${f.name}`, 'green');
  6867. await loadFileTree();
  6868. await loadProjectInfo();
  6869. autoOpenFirstFile();
  6870. } catch { setStatus('ZIP import failed', 'red'); }
  6871. };
  6872. reader.readAsDataURL(f);
  6873. return;
  6874. }
  6875. }
  6876. const files = [];
  6877. // Accept all common code/text file types
  6878. 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'];
  6879. const isCodeFile = (name) => {
  6880. const ext = name.includes('.') ? '.' + name.split('.').pop().toLowerCase() : '';
  6881. return !ext || codeExts.includes(ext); // extensionless files like Makefile are also ok
  6882. };
  6883. if (items[0]?.getAsFileSystemHandle) {
  6884. try {
  6885. for (const item of items) {
  6886. const handle = await item.getAsFileSystemHandle();
  6887. if (handle.kind === 'directory') files.push(...await readDirHandle(handle, ''));
  6888. else if (handle.kind === 'file' && isCodeFile(handle.name)) {
  6889. const content = await (await handle.getFile()).text();
  6890. files.push({ path: handle.name, content });
  6891. }
  6892. }
  6893. await uploadFiles(files); return;
  6894. } catch {}
  6895. }
  6896. const entries = [...items].map(i => i.webkitGetAsEntry?.()).filter(Boolean);
  6897. if (entries.length) {
  6898. async function readEntry(entry, prefix) {
  6899. return new Promise(resolve => {
  6900. if (entry.isFile) {
  6901. if (isCodeFile(entry.name)) {
  6902. entry.file(f => { const r = new FileReader(); r.onload = () => {
  6903. files.push({ path: prefix ? `${prefix}/${entry.name}` : entry.name, content: r.result }); resolve();
  6904. }; r.readAsText(f); });
  6905. } else resolve();
  6906. } else if (entry.isDirectory) {
  6907. const dr = entry.createReader();
  6908. dr.readEntries(async subs => { const ep = prefix ? `${prefix}/${entry.name}` : entry.name; for (const s of subs) await readEntry(s, ep); resolve(); });
  6909. } else resolve();
  6910. });
  6911. }
  6912. for (const entry of entries) await readEntry(entry, '');
  6913. await uploadFiles(files);
  6914. }
  6915. });
  6916. // ===================== ASK USER QUESTION (Interactive Choices) =====================
  6917. function showAskUserWidget(data) {
  6918. const container = $('chatMessages');
  6919. const div = document.createElement('div');
  6920. div.className = 'ask-user-widget';
  6921. const inputType = data.multiSelect ? 'checkbox' : 'radio';
  6922. let optionsHtml = '';
  6923. for (let i = 0; i < data.options.length; i++) {
  6924. const opt = data.options[i];
  6925. optionsHtml += `<div class="ask-user-option" onclick="toggleAskOption(this, '${inputType}')">
  6926. <input type="${inputType}" name="ask-opt" value="${i}">
  6927. <div><div class="opt-label">${escapeHtml(opt.label)}</div>${opt.description ? `<div class="opt-desc">${escapeHtml(opt.description)}</div>` : ''}</div>
  6928. </div>`;
  6929. }
  6930. div.innerHTML = `
  6931. <div class="ask-question">${escapeHtml(data.question)}</div>
  6932. ${optionsHtml}
  6933. <div class="ask-user-other"><input type="text" id="askOtherInput" placeholder="Or type your own answer..."></div>
  6934. <div class="ask-user-submit"><button onclick="submitAskAnswer(this.closest('.ask-user-widget'))">Submit</button></div>`;
  6935. container.appendChild(div);
  6936. scrollChat();
  6937. }
  6938. function toggleAskOption(optEl, type) {
  6939. if (type === 'radio') {
  6940. optEl.closest('.ask-user-widget').querySelectorAll('.ask-user-option').forEach(el => el.classList.remove('selected'));
  6941. }
  6942. optEl.classList.toggle('selected');
  6943. optEl.querySelector('input').checked = optEl.classList.contains('selected');
  6944. }
  6945. async function submitAskAnswer(widget) {
  6946. const otherInput = widget.querySelector('#askOtherInput').value.trim();
  6947. if (otherInput) {
  6948. await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer: otherInput, chatId: activeConvId }) });
  6949. } else {
  6950. const selected = [...widget.querySelectorAll('input:checked')].map(inp => {
  6951. const opt = inp.closest('.ask-user-option').querySelector('.opt-label');
  6952. return opt ? opt.textContent : '';
  6953. }).filter(Boolean);
  6954. const answer = selected.length ? selected.join(', ') : 'No selection';
  6955. await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer, chatId: activeConvId }) });
  6956. }
  6957. // Dim the widget after answering
  6958. widget.style.opacity = '0.5';
  6959. widget.querySelector('.ask-user-submit button').disabled = true;
  6960. }
  6961. // ===================== SKILL PALETTE =====================
  6962. let cachedSkills = null;
  6963. let skillIdx = -1;
  6964. // Client-side commands (instant, no LLM)
  6965. const CLIENT_COMMANDS = [
  6966. { name: 'help', description: 'Show all available commands' },
  6967. { name: 'clear', description: 'Clear conversation context (fresh start)' },
  6968. { name: 'context', description: 'Show context window usage' },
  6969. { name: 'compile', description: 'Compile project and get preview URLs' },
  6970. { name: 'status', description: 'Show project status summary' },
  6971. { name: 'docs', description: 'Sync VL reference docs from DocCenter' },
  6972. { name: 'version', description: 'Show VL-Code version' },
  6973. { name: 'screenshot', description: 'Take IDE screenshot (self-test via Playwright)' },
  6974. { name: 'console', description: 'Show browser console logs (errors/warnings)' },
  6975. { name: 'inspect', description: 'Evaluate JS expression in browser context' },
  6976. { name: 'test', description: 'List VL component instance-ids in compiled preview' },
  6977. { name: 'syntax', description: 'Look up VL syntax reference (widget, section, rules)' },
  6978. { name: 'cookie', description: 'Refresh cloud cookie from global auth or paste a new one' },
  6979. { name: 'compile-errors', description: 'Show last compile errors from parsevl' },
  6980. ];
  6981. function handleClientCommand(name, args) {
  6982. switch (name) {
  6983. case 'help': {
  6984. const cmds = CLIENT_COMMANDS.map(c => ` **/${c.name}** — ${c.description}`).join('\n');
  6985. const skills = (cachedSkills || []).map(s => ` **/${s.name}** — ${s.description}`).join('\n');
  6986. addMsg('assistant', `**Available Commands**\n\n_Client commands (instant):_\n${cmds}\n\n_AI skills (LLM-powered):_\n${skills || ' Loading...'}`);
  6987. return true;
  6988. }
  6989. case 'clear':
  6990. fetch('/api/conversations', { method: 'DELETE' }).catch(() => {});
  6991. $('chatMessages').innerHTML = '';
  6992. _lastBackendMsgCount = 0;
  6993. conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  6994. activeConvId = 0; convIdCounter = 1;
  6995. localStorage.removeItem(chatStorageKey());
  6996. saveChatState();
  6997. renderConvTabs();
  6998. addMsg('assistant', 'Context cleared. Starting fresh conversation.');
  6999. return true;
  7000. case 'context':
  7001. updateContext();
  7002. api('/api/context').then(ctx => {
  7003. const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
  7004. 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`);
  7005. });
  7006. return true;
  7007. case 'compile':
  7008. compileProject();
  7009. return true;
  7010. case 'status':
  7011. api('/api/project').then(proj => {
  7012. const s = proj.summary || {};
  7013. const files = s.totalFiles || 0;
  7014. const types = s.filesByType ? Object.entries(s.filesByType).map(([k,v]) => `${k}: ${v}`).join(', ') : 'none';
  7015. 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}`);
  7016. });
  7017. return true;
  7018. case 'docs':
  7019. syncVLDocs();
  7020. return true;
  7021. case 'version':
  7022. api('/api/version').then(v => addMsg('assistant', `VL-Code **v${v.version}**`));
  7023. return true;
  7024. case 'screenshot': {
  7025. addMsg('assistant', 'Taking screenshot via Playwright...');
  7026. fetch('/api/browser/screenshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: args || 'ide_' + Date.now() }) })
  7027. .then(r => r.json()).then(data => {
  7028. if (data.error) addMsg('assistant', 'Screenshot error: ' + data.error);
  7029. 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()}_`);
  7030. }).catch(e => addMsg('assistant', 'Screenshot failed: ' + e.message));
  7031. return true;
  7032. }
  7033. case 'console': {
  7034. fetch('/api/browser/console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filter: args || 'error' }) })
  7035. .then(r => r.json()).then(data => {
  7036. if (!data.logs?.length) { addMsg('assistant', `No ${args || 'error'} logs found.`); return; }
  7037. const lines = data.logs.map(l => `[${l.type}] ${l.text}`).join('\n');
  7038. addMsg('assistant', `**Browser Console** (${data.logs.length}/${data.total}):\n\`\`\`\n${lines}\n\`\`\``);
  7039. }).catch(e => addMsg('assistant', 'Console fetch failed: ' + e.message));
  7040. return true;
  7041. }
  7042. case 'inspect': {
  7043. if (!args) { addMsg('assistant', 'Usage: /inspect <js expression>'); return true; }
  7044. fetch('/api/browser/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expression: args }) })
  7045. .then(r => r.json()).then(data => {
  7046. if (data.error) addMsg('assistant', 'Eval error: ' + data.error);
  7047. else addMsg('assistant', `**Result:**\n\`\`\`\n${data.result}\n\`\`\``);
  7048. }).catch(e => addMsg('assistant', 'Inspect failed: ' + e.message));
  7049. return true;
  7050. }
  7051. case 'test': {
  7052. addMsg('assistant', 'Scanning VL components in compiled preview...');
  7053. const previewUrl = args || null;
  7054. const body = previewUrl ? { action: 'open', url: previewUrl } : { action: 'listIds' };
  7055. // If a URL is provided, open it first then listIds
  7056. if (previewUrl) {
  7057. fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open', url: previewUrl }) })
  7058. .then(r => r.json()).then(openRes => {
  7059. if (openRes.error) { addMsg('assistant', 'Open failed: ' + openRes.error); return; }
  7060. return fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) }).then(r => r.json());
  7061. }).then(data => {
  7062. if (!data) return;
  7063. if (data.error) { addMsg('assistant', 'listIds error: ' + data.error); return; }
  7064. const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
  7065. addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
  7066. }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
  7067. } else {
  7068. fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) })
  7069. .then(r => r.json()).then(data => {
  7070. if (data.error) { addMsg('assistant', 'listIds error: ' + data.error + '\n\n_Usage: /test <preview-url> to open a VL preview first_'); return; }
  7071. const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
  7072. addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
  7073. }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
  7074. }
  7075. return true;
  7076. }
  7077. case 'compile-errors': {
  7078. fetch('/api/compile/errors').then(r => r.json()).then(data => {
  7079. if (!data.errList?.length) {
  7080. addMsg('assistant', data.message || 'No compile errors. Last compile was clean.');
  7081. return;
  7082. }
  7083. const errLines = data.errList.map((e, i) => {
  7084. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  7085. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  7086. return ` ${i + 1}. ${JSON.stringify(e)}`;
  7087. }).join('\n');
  7088. 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._`);
  7089. }).catch(e => addMsg('assistant', 'Failed to fetch compile errors: ' + e.message));
  7090. return true;
  7091. }
  7092. case 'syntax': {
  7093. 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; }
  7094. const parts = args.trim().split(/\s+/);
  7095. let body;
  7096. if (parts[0] === 'rules') body = { action: 'rules' };
  7097. else if (parts[0] === 'widget' && parts[1]) body = { action: 'widget', query: parts.slice(1).join(' ') };
  7098. else if (parts[0] === 'section' && parts[1]) body = { action: 'section', query: parts.slice(1).join(' ') };
  7099. else body = { action: 'search', query: args.trim() };
  7100. addMsg('assistant', `Looking up VL syntax: **${args.trim()}**...`);
  7101. fetch('/api/vl-syntax', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
  7102. .then(r => r.json()).then(data => {
  7103. 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; }
  7104. 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; }
  7105. 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; }
  7106. if (data.availableSections) { addMsg('assistant', `**Available sections:**\n${data.availableSections.map(s => ` - **${s.id}** — ${s.title}`).join('\n')}`); return; }
  7107. addMsg('assistant', '```json\n' + JSON.stringify(data, null, 2) + '\n```');
  7108. }).catch(e => addMsg('assistant', 'Syntax lookup failed: ' + e.message));
  7109. return true;
  7110. }
  7111. case 'cookie': {
  7112. if (args && args.trim().length > 20) {
  7113. // User pasted a cookie directly: /cookie <jwt>
  7114. addMsg('assistant', 'Setting cookie...');
  7115. fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cookie: args.trim() }) })
  7116. .then(r => r.json()).then(d => {
  7117. if (d.ok) { addMsg('assistant', 'Cookie updated. Cloud features ready.'); initCloudStatus(); }
  7118. else addMsg('assistant', 'Failed to set cookie: ' + (d.error || 'unknown'));
  7119. }).catch(e => addMsg('assistant', 'Error: ' + e.message));
  7120. } else {
  7121. // Refresh cookie from global auth
  7122. addMsg('assistant', 'Refreshing cookie from global auth...');
  7123. fetch('/api/cookie/refresh', { method: 'POST' })
  7124. .then(r => r.json()).then(d => {
  7125. if (d.ok) { addMsg('assistant', `Cookie refreshed (source: ${d.source || 'auth.json'}). User: **${d.userId || '?'}**`); initCloudStatus(); }
  7126. else addMsg('assistant', 'No cookie found. Use `/cookie <jwt>` to paste one, or login via Cloud panel.');
  7127. }).catch(e => addMsg('assistant', 'Error: ' + e.message));
  7128. }
  7129. return true;
  7130. }
  7131. default:
  7132. return false;
  7133. }
  7134. }
  7135. async function showSkillPalette() {
  7136. if (!cachedSkills) {
  7137. try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
  7138. }
  7139. const palette = $('skillPalette');
  7140. palette.innerHTML = '';
  7141. skillIdx = -1;
  7142. const input = $('chatInput').value.slice(1).toLowerCase();
  7143. // Combine client commands + server skills
  7144. const all = [
  7145. ...CLIENT_COMMANDS.map(c => ({ ...c, isClient: true })),
  7146. ...(cachedSkills || []).map(s => ({ ...s, isClient: false })),
  7147. ];
  7148. const filtered = all.filter(s => s.name.includes(input) || s.description.toLowerCase().includes(input));
  7149. for (const skill of filtered) {
  7150. const item = document.createElement('div');
  7151. item.className = 'skill-item';
  7152. const badge = skill.isClient ? '<span style="font-size:8px;color:var(--green);margin-left:2px;">&#9679;</span>' : '';
  7153. item.innerHTML = `<span class="sk-name">/${escapeHtml(skill.name)}${badge}</span><span class="sk-desc">${escapeHtml(skill.description)}</span>`;
  7154. item.onclick = () => selectSkill(skill.name);
  7155. palette.appendChild(item);
  7156. }
  7157. if (filtered.length > 0) palette.classList.add('open');
  7158. else palette.classList.remove('open');
  7159. }
  7160. function selectSkill(name) {
  7161. $('chatInput').value = `/${name} `;
  7162. $('chatInput').focus();
  7163. $('skillPalette').classList.remove('open');
  7164. }
  7165. // Extend chatInput handler to detect / commands
  7166. const origInputHandler = $('chatInput').oninput;
  7167. $('chatInput').addEventListener('input', function() {
  7168. const val = this.value;
  7169. if (val.startsWith('/') && !val.includes(' ')) {
  7170. showSkillPalette();
  7171. } else {
  7172. $('skillPalette').classList.remove('open');
  7173. }
  7174. });
  7175. // ===================== CONVERSATION SEARCH =====================
  7176. function openChatSearch() {
  7177. $('chatSearch').classList.add('open');
  7178. $('chatSearchInput').focus();
  7179. }
  7180. function closeChatSearch() {
  7181. $('chatSearch').classList.remove('open');
  7182. $('chatSearchInput').value = '';
  7183. $('searchCount').textContent = '';
  7184. // Remove highlights
  7185. $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
  7186. el.replaceWith(el.textContent);
  7187. });
  7188. }
  7189. function searchConversation(query) {
  7190. // Remove old highlights
  7191. $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
  7192. el.replaceWith(el.textContent);
  7193. });
  7194. if (!query || query.length < 2) { $('searchCount').textContent = ''; return; }
  7195. let count = 0;
  7196. const msgs = $('chatMessages').querySelectorAll('.msg .content-text');
  7197. const q = query.toLowerCase();
  7198. for (const el of msgs) {
  7199. if (el.textContent.toLowerCase().includes(q)) {
  7200. count++;
  7201. // Highlight matches (simple text node replacement)
  7202. const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  7203. const textNodes = [];
  7204. while (walker.nextNode()) textNodes.push(walker.currentNode);
  7205. for (const node of textNodes) {
  7206. if (node.textContent.toLowerCase().includes(q)) {
  7207. const parts = node.textContent.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'));
  7208. if (parts.length > 1) {
  7209. const span = document.createElement('span');
  7210. for (const part of parts) {
  7211. if (part.toLowerCase() === q) {
  7212. const mark = document.createElement('mark');
  7213. mark.className = 'search-highlight';
  7214. mark.style.cssText = 'background:var(--yellow);color:var(--bg);border-radius:2px;padding:0 1px;';
  7215. mark.textContent = part;
  7216. span.appendChild(mark);
  7217. } else {
  7218. span.appendChild(document.createTextNode(part));
  7219. }
  7220. }
  7221. node.replaceWith(span);
  7222. }
  7223. }
  7224. }
  7225. }
  7226. }
  7227. $('searchCount').textContent = count > 0 ? `${count} found` : 'No matches';
  7228. }
  7229. // Cmd/Ctrl+F in chat: open search
  7230. document.addEventListener('keydown', e => {
  7231. if ((e.metaKey || e.ctrlKey) && e.key === 'f' && document.activeElement?.closest('.chat-panel')) {
  7232. e.preventDefault();
  7233. openChatSearch();
  7234. }
  7235. });
  7236. // ===================== EXTEND SENDMESSAGE FOR SKILLS =====================
  7237. const origSendMessage = sendMessage;
  7238. sendMessage = async function() {
  7239. const input = $('chatInput');
  7240. const msg = input.value.trim();
  7241. $('skillPalette').classList.remove('open');
  7242. // Handle skill commands
  7243. if (msg.startsWith('/') && !msg.startsWith('//')) {
  7244. const parts = msg.substring(1).split(/\s+/);
  7245. const skillName = parts[0];
  7246. const args = parts.slice(1).join(' ');
  7247. // Client-side commands (no LLM needed)
  7248. const clientCmd = handleClientCommand(skillName, args);
  7249. if (clientCmd) { input.value = ''; return; }
  7250. // Check if it's a known skill
  7251. if (!cachedSkills) {
  7252. try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
  7253. }
  7254. const skill = cachedSkills.find(s => s.name === skillName);
  7255. if (skill) {
  7256. input.value = '';
  7257. $('chatSend').disabled = true;
  7258. $('chatSend').style.display = 'none';
  7259. $('chatStop').style.display = '';
  7260. _currentAbortController = new AbortController();
  7261. setStatus(`Running /${skillName}...`, 'yellow');
  7262. addMsg('user', msg);
  7263. try {
  7264. const res = await fetch('/api/skill', {
  7265. method:'POST', headers:{'Content-Type':'application/json'},
  7266. body: JSON.stringify({ skill: skillName, args, chatId: activeConvId }),
  7267. signal: _currentAbortController?.signal,
  7268. });
  7269. startSpinnerSafetyTimeout();
  7270. const reader = res.body.getReader();
  7271. const decoder = new TextDecoder();
  7272. let assistantEl = null;
  7273. let buffer = '';
  7274. let currentEvent = '';
  7275. while (true) {
  7276. const {done, value} = await reader.read();
  7277. if (done) break;
  7278. buffer += decoder.decode(value, {stream:true});
  7279. const lines = buffer.split('\n');
  7280. buffer = lines.pop();
  7281. for (const line of lines) {
  7282. if (line.startsWith('event: ')) { currentEvent = line.slice(7); continue; }
  7283. if (line.startsWith('data: ')) {
  7284. try {
  7285. const data = JSON.parse(line.slice(6));
  7286. debugLog(currentEvent || 'data', data);
  7287. if (currentEvent === 'thinking') {
  7288. if (data.phase === 'start') addThinkingIndicator();
  7289. else if (data.phase === 'delta' && data.text) appendThinkingText(data.text);
  7290. else if (data.phase === 'end') finalizeThinking();
  7291. } else if (currentEvent === 'ask_user') {
  7292. showAskUserWidget(data);
  7293. } else if (currentEvent === 'plan_mode') {
  7294. handlePlanModeEvent(data);
  7295. } else if (data.text) {
  7296. if (!assistantEl) { assistantEl = addMsg('assistant', ''); assistantEl.querySelector('.content-text').dataset.raw = ''; }
  7297. const textEl = assistantEl.querySelector('.content-text');
  7298. textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
  7299. textEl.textContent += data.text;
  7300. scrollChat();
  7301. } else if (data.name && data.input !== undefined) {
  7302. addToolIndicator(data.name, data.input, 'running', data.detail);
  7303. } else if (data.name && data.preview !== undefined) {
  7304. updateToolIndicator(data.name, data.preview);
  7305. } else if (data.todos) {
  7306. renderTodos(data.todos);
  7307. } else if (currentEvent === 'workflow_generated') {
  7308. if (data.workflow) {
  7309. showModeIframe('workflow', '/workflow-editor.html', async () => {
  7310. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
  7311. });
  7312. }
  7313. } else if (currentEvent === 'node_start') {
  7314. forwardWorkflowEventToIframe('node_start', data);
  7315. } else if (currentEvent === 'node_done') {
  7316. forwardWorkflowEventToIframe('node_done', data);
  7317. } else if (currentEvent === 'node_error') {
  7318. forwardWorkflowEventToIframe('node_error', data);
  7319. } else if (currentEvent === 'screenshot' && data.screenshots?.length) {
  7320. if (!assistantEl) assistantEl = addMsg('assistant', '');
  7321. for (const ssName of data.screenshots) {
  7322. const url = `/api/browser/screenshot/${ssName}`;
  7323. appendScreenshotToChat(assistantEl, url, ssName);
  7324. _contextScreenshots.push({ url, name: ssName });
  7325. }
  7326. } else if (currentEvent === 'done') {
  7327. finalizeAssistantMsg(assistantEl);
  7328. finalizeAllToolSpinners();
  7329. clearSpinnerSafetyTimeout();
  7330. if (data.msgCount !== undefined) _lastBackendMsgCount = data.msgCount;
  7331. } else if (currentEvent === 'error') {
  7332. if (!assistantEl) assistantEl = addMsg('assistant', '');
  7333. assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
  7334. }
  7335. } catch {}
  7336. }
  7337. }
  7338. }
  7339. } catch(e) {
  7340. if (e.name === 'AbortError') {
  7341. addMsg('assistant', '⏹ Stopped by user.');
  7342. } else {
  7343. addMsg('assistant', 'Skill error: ' + e.message);
  7344. }
  7345. finalizeAllToolSpinners();
  7346. }
  7347. clearSpinnerSafetyTimeout();
  7348. _currentAbortController = null;
  7349. $('chatStop').style.display = 'none';
  7350. $('chatSend').style.display = '';
  7351. $('chatSend').disabled = false;
  7352. setStatus('Ready', 'green');
  7353. updateContext();
  7354. return;
  7355. }
  7356. }
  7357. // Fall through to original sendMessage
  7358. return origSendMessage.call(this);
  7359. };
  7360. // Also handle ask_user events in the main chat SSE stream
  7361. const origSSEParseLine = null; // We need to modify the sendMessage SSE handler
  7362. // Patch: intercept ask_user events in main sendMessage flow
  7363. // This is done by modifying the sendMessage function's SSE parsing
  7364. // The easiest approach: also check for ask_user in the main sendMessage
  7365. // Let's patch it by adding ask_user handling to the main SSE loop
  7366. // ===================== SPECIAL TAB HELPERS =====================
  7367. /** Open workflow DAG tab */
  7368. function openWorkflowTab(workflowData, title) {
  7369. openSpecialTab('__workflow__', 'workflow', title || 'Workflow DAG', workflowData);
  7370. }
  7371. /** Open metadata visualization tab */
  7372. function openMetadataTab(metaData, title) {
  7373. if (currentMode === 'meta') {
  7374. showModeIframe('metadata', '/metadata-viewer.html', async () =>
  7375. metaData ? { type: 'loadMetadata', data: metaData } : null
  7376. );
  7377. } else {
  7378. _setMapIndicator(!!metaData);
  7379. }
  7380. }
  7381. /** Send message to a special tab iframe */
  7382. function postToSpecialTab(key, message) {
  7383. const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
  7384. if (iframe?.contentWindow) iframe.contentWindow.postMessage(message, '*');
  7385. }
  7386. /** Update workflow node status (called during workflow execution) */
  7387. function updateWorkflowNode(nodeId, status) {
  7388. const msg = { type: 'updateNodeStatus', nodeId, status };
  7389. // Try both special-tab and mode-iframe keys
  7390. postToSpecialTab('__workflow__', msg);
  7391. sendToWorkflowIframe(msg);
  7392. }
  7393. // ===================== WORKFLOW MANAGEMENT =====================
  7394. let cachedWorkflows = null;
  7395. let activeWorkflowName = null;
  7396. let _selectedCodegenWorkflow = 'parallel'; // server-persisted default
  7397. const CODEGEN_WORKFLOWS = {
  7398. 'parallel': { label: 'Parallel', desc: 'Default: Theme.vth + fully parallel VL fanout', file: 'parallel-codegen' },
  7399. 'meta-direct': { label: 'Meta-Direct', desc: 'Small projects: direct ProjectMeta + parallel VL fanout', file: 'meta-direct-codegen' },
  7400. '3-file': { label: '3-File', desc: 'Medium: PRD + ServiceMap + UIMap + Theme.vth', file: '3-file-codegen' },
  7401. '6-file': { label: '6-File', desc: 'Medium-large: 6 specs + Theme.vth + parallel VL fanout', file: '6-file-codegen' },
  7402. '9-file': { label: '9-File', desc: 'Large: 9 specs + Theme.vth + parallel VL fanout', file: '9-file-codegen' },
  7403. };
  7404. const ADJUST_WORKFLOWS = {
  7405. 'add-page': { label: 'Add Page', desc: 'Add new page: section + components + route', file: 'add-page' },
  7406. 'add-service': { label: 'Add Service', desc: 'Add new backend service domain + DB schema', file: 'add-service' },
  7407. 'theme-customize': { label: 'Theme', desc: 'Customize theme tokens + cascade updates', file: 'theme-customize' },
  7408. 'general': { label: 'General', desc: 'General changes via Meta diff + affected file regen', file: 'incremental-update' },
  7409. };
  7410. function toggleWorkflowPanel() {
  7411. const dd = $('wfDropdown');
  7412. dd.classList.toggle('open');
  7413. if (dd.classList.contains('open')) {
  7414. renderCodegenOptions();
  7415. renderAdjustOptions();
  7416. loadWorkflowList();
  7417. }
  7418. }
  7419. function toggleWfAllList() {
  7420. const list = $('wfList');
  7421. const toggle = $('wfAllToggle');
  7422. if (list.style.display === 'none') {
  7423. list.style.display = 'block';
  7424. toggle.innerHTML = '&#9660;';
  7425. } else {
  7426. list.style.display = 'none';
  7427. toggle.innerHTML = '&#9654;';
  7428. }
  7429. }
  7430. function renderCodegenOptions() {
  7431. const container = $('wfCodegenOptions');
  7432. container.innerHTML = '';
  7433. for (const [key, info] of Object.entries(CODEGEN_WORKFLOWS)) {
  7434. const div = document.createElement('div');
  7435. div.className = 'wf-item';
  7436. div.style.cursor = 'pointer';
  7437. if (key === _selectedCodegenWorkflow) {
  7438. div.style.background = 'var(--accent)';
  7439. div.style.color = '#fff';
  7440. div.style.borderRadius = '4px';
  7441. }
  7442. 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>`;
  7443. div.onclick = () => selectCodegenWorkflow(key);
  7444. container.appendChild(div);
  7445. }
  7446. }
  7447. function renderAdjustOptions() {
  7448. const container = $('wfAdjustOptions');
  7449. container.innerHTML = '';
  7450. for (const [key, info] of Object.entries(ADJUST_WORKFLOWS)) {
  7451. const div = document.createElement('div');
  7452. div.className = 'wf-item';
  7453. div.style.cursor = 'pointer';
  7454. 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>`;
  7455. div.onclick = () => { $('wfDropdown').classList.remove('open'); setStatus(`Adjustment workflow: ${info.label} (used automatically by VLAdjust tool)`, 'green'); };
  7456. container.appendChild(div);
  7457. }
  7458. }
  7459. async function selectCodegenWorkflow(key) {
  7460. _selectedCodegenWorkflow = key;
  7461. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[key].label;
  7462. $('wfDropdown').classList.remove('open');
  7463. // Persist on server
  7464. try { await api('/api/workflow-selection', { method: 'POST', body: JSON.stringify({ workflow: key }), headers: { 'Content-Type': 'application/json' } }); } catch {}
  7465. // Also update local binding
  7466. workflowBindings.generate = CODEGEN_WORKFLOWS[key].file;
  7467. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
  7468. setStatus(`Codegen workflow: ${CODEGEN_WORKFLOWS[key].label}`, 'green');
  7469. }
  7470. async function loadCodegenWorkflowSelection() {
  7471. try {
  7472. const data = await api('/api/workflow-selection');
  7473. if (data.defaultWorkflow && CODEGEN_WORKFLOWS[data.defaultWorkflow]) {
  7474. _selectedCodegenWorkflow = data.defaultWorkflow;
  7475. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.defaultWorkflow].label;
  7476. workflowBindings.generate = CODEGEN_WORKFLOWS[data.defaultWorkflow].file;
  7477. }
  7478. } catch {}
  7479. }
  7480. // Close workflow panel on click outside
  7481. document.addEventListener('click', e => {
  7482. if (!e.target.closest('.wf-selector')) $('wfDropdown').classList.remove('open');
  7483. });
  7484. async function loadWorkflowList() {
  7485. try {
  7486. const data = await api('/api/workflows');
  7487. cachedWorkflows = data.workflows;
  7488. const list = $('wfList');
  7489. list.innerHTML = '';
  7490. if (!data.workflows.length) {
  7491. list.innerHTML = '<div style="padding:8px 10px;font-size:10px;color:var(--text2);">No workflows found</div>';
  7492. return;
  7493. }
  7494. for (const wf of data.workflows) {
  7495. const div = document.createElement('div');
  7496. div.className = 'wf-item';
  7497. if (wf.name === activeWorkflowName) div.style.background = 'var(--bg3)';
  7498. 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>`;
  7499. div.onclick = () => { activeWorkflowName = wf.name; $('wfDropdown').classList.remove('open'); setStatus('Workflow: ' + (wf.title || wf.name), 'green'); };
  7500. list.appendChild(div);
  7501. }
  7502. } catch { $('wfList').innerHTML = '<div style="padding:8px;font-size:10px;color:var(--red)">Failed to load</div>'; }
  7503. }
  7504. async function viewWorkflow(name) {
  7505. try {
  7506. const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
  7507. const wf = data.workflow || (data.steps ? data : null);
  7508. if (wf) {
  7509. // Switch to Flow tab and load the workflow for viewing
  7510. if (currentMode !== 'flow') switchMode('flow');
  7511. showModeIframe('workflow', '/workflow-editor.html', async () => {
  7512. return { type: 'loadWorkflow', data: wf, workflowName: name };
  7513. });
  7514. }
  7515. } catch (e) { setStatus('Failed to load workflow', 'red'); }
  7516. $('wfDropdown').classList.remove('open');
  7517. }
  7518. async function viewMetadataTab() {
  7519. $('wfDropdown').classList.remove('open');
  7520. try {
  7521. // Check if VL project first
  7522. const proj = await api('/api/project');
  7523. if (!proj.isVL) {
  7524. setStatus('No VL files — cannot extract metadata', 'yellow');
  7525. switchMode('meta');
  7526. openMetadataTab(null, 'Project Meta');
  7527. return;
  7528. }
  7529. let data = await api('/api/metadata');
  7530. if (!_hasRenderableMetadata(data.meta)) {
  7531. setStatus('Extracting metadata...', 'yellow');
  7532. data = await api('/api/metadata/extract');
  7533. if (data.meta) setStatus('Metadata extracted', 'green');
  7534. else setStatus('No metadata found', 'yellow');
  7535. }
  7536. switchMode('meta');
  7537. openMetadataTab(data.meta || null, 'Project Meta');
  7538. } catch {
  7539. switchMode('meta');
  7540. openMetadataTab(null, 'Project Meta');
  7541. }
  7542. }
  7543. // ===================== MODE TABS =====================
  7544. /** Show/hide green dot on Map tab indicating metadata is available */
  7545. function _setMapIndicator(show) {
  7546. const dot = $('mapReadyDot');
  7547. if (dot) dot.style.display = show ? 'inline-block' : 'none';
  7548. }
  7549. function _hasRenderableMetadata(meta) {
  7550. if (!meta || typeof meta !== 'object') return false;
  7551. if ((meta.apps || []).length > 0) return true;
  7552. if ((meta.sections || []).length > 0) return true;
  7553. if ((meta.components || []).length > 0) return true;
  7554. if ((meta.services || meta.serviceDomains || []).length > 0) return true;
  7555. if ((meta.dataSchema?.tables || meta.tables || meta.database?.tables || []).length > 0) return true;
  7556. return false;
  7557. }
  7558. async function resolveDocCenterEmbedSrc({ force = false } = {}) {
  7559. return force
  7560. ? `/doc-center.html?embed=ide&t=${Date.now()}`
  7561. : '/doc-center.html?embed=ide';
  7562. }
  7563. async function showDocCenterMode() {
  7564. $('editorTabs').style.display = 'none';
  7565. $('cmEditorWrap').style.display = 'none';
  7566. $('editor').style.display = 'none';
  7567. $('codePreview').style.display = 'none';
  7568. $('mdPreview').style.display = 'none';
  7569. $('editorPlaceholder').style.display = 'none';
  7570. $('iframeContainer').style.display = 'block';
  7571. const src = await resolveDocCenterEmbedSrc();
  7572. showModeIframe('docs', src, async () => null);
  7573. setStatus('Documentation ready', 'green');
  7574. }
  7575. /** Switch between Code / Map / Flow modes */
  7576. function switchMode(mode) {
  7577. // Clear map indicator when user visits Map tab
  7578. if (mode === 'meta') _setMapIndicator(false);
  7579. currentMode = mode;
  7580. // Update mode tab styling
  7581. document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
  7582. if (mode === 'code') {
  7583. // Show normal editor tabs + content — Code tab shows the selected file
  7584. $('editorTabs').style.display = 'flex';
  7585. $('iframeContainer').style.display = 'none';
  7586. $('codePreview').style.display = 'none';
  7587. $('mdPreview').style.display = 'none';
  7588. if (currentFile && openFiles.has(currentFile)) {
  7589. showTabContent(currentFile);
  7590. } else if (openFiles.size > 0) {
  7591. const keys = [...openFiles.keys()].filter(k => openFiles.get(k).type === 'file');
  7592. if (keys.length > 0) { currentFile = keys[keys.length - 1]; renderTabs(); showTabContent(currentFile); }
  7593. else { $('cmEditorWrap').style.display = 'none'; $('editor').style.display = 'none'; $('editorPlaceholder').style.display = 'block'; }
  7594. } else {
  7595. $('cmEditorWrap').style.display = 'none';
  7596. $('editor').style.display = 'none';
  7597. $('editorPlaceholder').style.display = 'block';
  7598. }
  7599. } else if (mode === 'meta') {
  7600. // Show metadata viewer
  7601. $('editorTabs').style.display = 'none';
  7602. $('cmEditorWrap').style.display = 'none';
  7603. $('editor').style.display = 'none';
  7604. $('codePreview').style.display = 'none';
  7605. $('mdPreview').style.display = 'none';
  7606. $('editorPlaceholder').style.display = 'none';
  7607. $('iframeContainer').style.display = 'block';
  7608. showModeIframe('metadata', '/metadata-viewer.html', async () => {
  7609. try {
  7610. // Check if this is a VL project first
  7611. const proj = await api('/api/project');
  7612. if (!proj.isVL) {
  7613. setStatus('No VL files — metadata extraction skipped', 'yellow');
  7614. return null;
  7615. }
  7616. // Try loading existing metadata
  7617. let data = await api('/api/metadata');
  7618. // If no metadata, auto-extract from VL files
  7619. if (!_hasRenderableMetadata(data.meta)) {
  7620. setStatus('Extracting metadata from VL files...', 'yellow');
  7621. data = await api('/api/metadata/extract');
  7622. if (data.meta) setStatus('Metadata extracted', 'green');
  7623. else setStatus('No metadata to extract', 'yellow');
  7624. }
  7625. return data.meta ? { type: 'loadMetadata', data: data.meta } : null;
  7626. } catch { return null; }
  7627. });
  7628. } else if (mode === 'flow') {
  7629. // Show workflow editor with Gen/Adjust toolbar
  7630. $('editorTabs').style.display = 'none';
  7631. $('previewBar').style.display = 'none';
  7632. $('cmEditorWrap').style.display = 'none';
  7633. $('editor').style.display = 'none';
  7634. $('editorPlaceholder').style.display = 'none';
  7635. $('flowToolbar').style.display = 'flex';
  7636. $('iframeContainer').style.display = 'block';
  7637. populateFlowWorkflowSelect();
  7638. loadActiveFlowWorkflow();
  7639. } else if (mode === 'docs') {
  7640. showDocCenterMode();
  7641. } else if (mode === 'preview') {
  7642. // Show live preview
  7643. $('editorTabs').style.display = 'none';
  7644. $('cmEditorWrap').style.display = 'none';
  7645. $('editor').style.display = 'none';
  7646. $('editorPlaceholder').style.display = 'none';
  7647. $('previewBar').style.display = 'flex';
  7648. $('iframeContainer').style.display = 'block';
  7649. loadPreviewApp();
  7650. }
  7651. // Hide bars when not in their respective modes
  7652. if (mode !== 'preview') $('previewBar').style.display = 'none';
  7653. if (mode !== 'flow') $('flowToolbar').style.display = 'none';
  7654. }
  7655. // ===================== PREVIEW =====================
  7656. let previewUrls = {}; // { appId: url }
  7657. /** Activate preview mode with URLs */
  7658. function activatePreview(urls) {
  7659. previewUrls = urls || {};
  7660. const keys = Object.keys(previewUrls);
  7661. if (keys.length === 0) {
  7662. $('previewUrlsPanel').style.display = 'none';
  7663. $('previewUrlLabel').textContent = '';
  7664. return;
  7665. }
  7666. // Show the Preview tab
  7667. $('previewModeTab').style.display = '';
  7668. // Populate app selector in preview bar
  7669. const sel = $('previewAppSelect');
  7670. sel.innerHTML = '';
  7671. for (const [appId, url] of Object.entries(previewUrls)) {
  7672. sel.innerHTML += `<option value="${appId}">${appId}</option>`;
  7673. }
  7674. sel.value = keys[0];
  7675. $('previewUrlLabel').textContent = previewUrls[keys[0]] || '';
  7676. // Populate sidebar preview URL list
  7677. const list = $('previewUrlsList');
  7678. list.innerHTML = '';
  7679. for (const [appId, url] of Object.entries(previewUrls)) {
  7680. const item = document.createElement('div');
  7681. item.className = 'preview-url-item';
  7682. item.innerHTML = `<span class="pui-name">${escapeHtml(appId)}</span><span class="pui-url">${escapeHtml(url)}</span>`;
  7683. item.onclick = () => { window.open(url, '_blank'); };
  7684. item.title = `Open ${url} in new tab`;
  7685. list.appendChild(item);
  7686. }
  7687. $('previewUrlsPanel').style.display = 'block';
  7688. // NOTE: Do NOT auto-switch to preview mode — user opens preview manually via Preview tab or sidebar links
  7689. }
  7690. function loadPreviewApp() {
  7691. const appId = $('previewAppSelect').value;
  7692. const url = previewUrls[appId];
  7693. if (!url) return;
  7694. $('previewUrlLabel').textContent = url;
  7695. // Always open in browser — no iframe embedding (cross-origin blocked by VL platform)
  7696. window.open(url, '_blank');
  7697. }
  7698. function refreshPreview() {
  7699. // Re-open current preview app in browser
  7700. const appId = $('previewAppSelect')?.value;
  7701. const url = previewUrls[appId];
  7702. if (url) window.open(url, '_blank');
  7703. }
  7704. function openPreviewExternal() {
  7705. const appId = $('previewAppSelect').value;
  7706. const url = previewUrls[appId];
  7707. if (url) window.open(url, '_blank');
  7708. }
  7709. /** Load preview URLs from project profile (saved by VLParse) */
  7710. async function loadPreviewUrlsFromProfile() {
  7711. try {
  7712. const profile = normalizeProjectProfile(await api('/api/profile'));
  7713. if (profile.previewUrls && Object.keys(profile.previewUrls).length > 0) {
  7714. activatePreview(profile.previewUrls);
  7715. }
  7716. } catch {}
  7717. }
  7718. /** Show/create a mode iframe (reused across switches) */
  7719. function showModeIframe(type, src, getDataFn) {
  7720. const container = $('iframeContainer');
  7721. const key = `__mode_${type}__`;
  7722. const resolvedSrc = new URL(src, window.location.href).href;
  7723. // Hide all iframes
  7724. [...container.children].forEach(f => f.style.display = 'none');
  7725. let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
  7726. const onLoad = async () => {
  7727. const msg = await getDataFn();
  7728. if (msg) iframe.contentWindow.postMessage(msg, '*');
  7729. };
  7730. if (!iframe) {
  7731. iframe = document.createElement('iframe');
  7732. iframe.dataset.tab = key;
  7733. iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups';
  7734. iframe.onload = onLoad;
  7735. iframe.src = src;
  7736. container.appendChild(iframe);
  7737. } else {
  7738. iframe.style.display = 'block';
  7739. iframe.onload = onLoad;
  7740. if (iframe.src !== resolvedSrc) {
  7741. iframe.src = src;
  7742. } else {
  7743. // Refresh data
  7744. getDataFn().then(msg => { if (msg) iframe.contentWindow.postMessage(msg, '*'); });
  7745. }
  7746. }
  7747. iframe.style.display = 'block';
  7748. }
  7749. // ===================== FOLDER PATH =====================
  7750. function openFolderInFinder() {
  7751. if (!currentWorkDir) return;
  7752. // Use backend to open folder (cross-platform)
  7753. fetch('/api/open-folder', {
  7754. method: 'POST',
  7755. headers: { 'Content-Type': 'application/json' },
  7756. body: JSON.stringify({ path: currentWorkDir }),
  7757. }).catch(() => {});
  7758. }
  7759. // ===================== WORKFLOW BINDINGS =====================
  7760. function loadWorkflowBindings() {
  7761. try {
  7762. const saved = localStorage.getItem('vl-code-wf-bindings');
  7763. if (saved) workflowBindings = JSON.parse(saved);
  7764. } catch {}
  7765. // Also load server-persisted codegen selection
  7766. loadCodegenWorkflowSelection();
  7767. }
  7768. function saveWorkflowBindings() {
  7769. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
  7770. }
  7771. // Listen for messages from embedded iframes
  7772. window.addEventListener('message', (e) => {
  7773. if (!e.data?.type) return;
  7774. if (e.data.type === 'nodeClick') {
  7775. // User clicked a node in workflow DAG — could highlight related file
  7776. setStatus(`Node: ${e.data.nodeId || e.data.nodeName || 'unknown'}`, 'green');
  7777. }
  7778. if (e.data.type === 'metaNodeClick') {
  7779. // User clicked a node in metadata graph — could navigate to related file
  7780. setStatus(`Meta: ${e.data.nodeType}/${e.data.nodeName || 'unknown'}`, 'green');
  7781. }
  7782. });
  7783. // Extend SSE handler for workflow/metadata events
  7784. const origConnectSSE = connectSSE;
  7785. connectSSE = function() {
  7786. origConnectSSE(); // proper setup: sets _sseSource, reconnect, base handlers
  7787. const es = _sseSource;
  7788. if (!es) return;
  7789. // On connect/reconnect: sync running workflow state from server so all tabs stay consistent
  7790. setTimeout(async () => {
  7791. try {
  7792. const state = await api('/api/workflow/current-state');
  7793. if (state.active && state.workflowName) {
  7794. _workflowActive = true;
  7795. _lastWorkflowName = state.workflowName;
  7796. window._skipFlowAutoLoad = true;
  7797. const wn = state.workflowName;
  7798. const syncRunToken = state.clientRunToken || state.runID || `state:${wn}`;
  7799. if (wn.startsWith('autotest')) switchFlowTab('autotest');
  7800. else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
  7801. else switchFlowTab('adjust');
  7802. await populateFlowWorkflowSelect();
  7803. _setFlowWfSelectOrStore(wn, $('flowWfSelect'));
  7804. updateFlowWfList();
  7805. await loadFlowWorkflow(wn);
  7806. forwardWorkflowEventToIframe('workflow_start', {
  7807. workflowName: wn,
  7808. name: wn,
  7809. runID: state.runID || null,
  7810. clientRunToken: syncRunToken,
  7811. });
  7812. if (state.checkpoint) {
  7813. sendToWorkflowIframe({
  7814. type: 'setCheckpoint',
  7815. checkpoint: state.checkpoint,
  7816. runID: state.runID || null,
  7817. clientRunToken: syncRunToken,
  7818. });
  7819. }
  7820. // Replay node statuses into DAG
  7821. for (const [nodeId, status] of Object.entries(state.nodeStatuses || {})) {
  7822. sendToWorkflowIframe({
  7823. type: 'updateNodeStatus',
  7824. nodeId,
  7825. status,
  7826. runID: state.runID || null,
  7827. clientRunToken: syncRunToken,
  7828. });
  7829. }
  7830. updateChatStatusBar(`Running workflow: ${wn}...`, '');
  7831. } else if (!state.active) {
  7832. window._skipFlowAutoLoad = false;
  7833. // Check localStorage for last workflow to restore view (not state) after refresh
  7834. try {
  7835. const saved = localStorage.getItem('vl-code-last-flow-wf');
  7836. if (saved) {
  7837. const { name, tab } = JSON.parse(saved);
  7838. if (name && tab) {
  7839. switchFlowTab(tab);
  7840. await loadFlowWorkflow(name);
  7841. }
  7842. localStorage.removeItem('vl-code-last-flow-wf');
  7843. }
  7844. } catch {}
  7845. }
  7846. } catch {}
  7847. }, 800);
  7848. function ensureWorkflowBroadcastChat(workflowName, meta = {}) {
  7849. const existing = window._wfBroadcastChatEl;
  7850. if (existing && document.body.contains(existing)) return existing;
  7851. const wfEl = addMsg('assistant', '');
  7852. wfEl.dataset.wfChat = 'true';
  7853. 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>`;
  7854. wfEl.querySelector('.content-text').innerHTML = header;
  7855. window._wfBroadcastChatEl = wfEl;
  7856. scrollChat();
  7857. return wfEl;
  7858. }
  7859. function appendWorkflowBroadcastChatLine(text, style = '', lineId = '') {
  7860. const wfEl = ensureWorkflowBroadcastChat(_lastWorkflowName || 'running');
  7861. if (!wfEl) return null;
  7862. const body = wfEl.querySelector('.content-text');
  7863. if (!body) return null;
  7864. if (lineId) {
  7865. const existing = document.getElementById(lineId);
  7866. if (existing) return existing;
  7867. }
  7868. const line = document.createElement('div');
  7869. if (lineId) line.id = lineId;
  7870. line.className = 'wf-chat-step';
  7871. line.style.cssText = style || 'font-size:11px;color:var(--text2);padding:2px 0;';
  7872. line.textContent = text;
  7873. body.appendChild(line);
  7874. scrollChat();
  7875. return line;
  7876. }
  7877. // Add extended handlers (workflow/autotest/metadata) — additive via addEventListener
  7878. es.addEventListener('message', (e) => {
  7879. try {
  7880. const data = JSON.parse(e.data);
  7881. // ── Workflow execution start (from VLGenerate / WorkflowRun tools) ──
  7882. // Auto-switch to Flow tab + load the workflow DAG for live visualization
  7883. if (data.type === 'workflow_execution_start') {
  7884. _workflowActive = true;
  7885. addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || 'unknown'}`, null, 'info');
  7886. updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
  7887. if (data.workflow) {
  7888. // Set _skipFlowAutoLoad flag to prevent switchMode('flow') from loading a different workflow
  7889. window._skipFlowAutoLoad = true;
  7890. // Load the actual workflow JSON into the Flow tab
  7891. showModeIframe('workflow', '/workflow-editor.html', async () => {
  7892. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || null };
  7893. });
  7894. // Clear previous node statuses after iframe loads
  7895. setTimeout(() => sendToWorkflowIframe({ type: 'clearStatus' }), 300);
  7896. // Keep _skipFlowAutoLoad true while workflow is active (cleared on workflow_done/error)
  7897. }
  7898. }
  7899. // ── Workflow node status updates ──
  7900. // Detail panel: ALL node updates (full log)
  7901. // Chat: only status bar update (no duplication)
  7902. // Flow DAG: highlight animation
  7903. if (data.type === 'workflow_node_update') {
  7904. updateWorkflowNode(data.nodeId, data.status);
  7905. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: data.nodeId, status: data.status, runID: data.runID || null, clientRunToken: data.clientRunToken || null });
  7906. // Skip addDetailEntry here — wf_node_start/done/error handlers create step cards instead
  7907. // Only keep detail entries for autotest case-level updates (have caseId)
  7908. if (data.caseId) {
  7909. const nodeLabel = (data.nodeId || '').replace(/_/g, ' ');
  7910. const statusIcon = data.status === 'done' ? '>' : data.status === 'error' ? 'x' : data.status === 'running' ? '>' : '-';
  7911. const detailType = data.status === 'error' ? 'error' : data.status === 'done' ? 'success' : 'info';
  7912. addDetailEntry('node', `${statusIcon} [${data.caseId}] ${nodeLabel} [${data.status}]`, null, detailType, { depth: 2 });
  7913. }
  7914. // Auto-load test-case sub-workflow when step nodes start running
  7915. // STEP_xxx nodes indicate per-step progress — auto-switch to that test case's workflow
  7916. if (data.caseId && data.nodeId?.startsWith('STEP_') && data.status === 'running' && data.nodeId === 'STEP_001') {
  7917. const tcWfName = `autotest-tc-${(data.caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  7918. loadWorkflowIntoFlowTab(tcWfName);
  7919. // Also select it in the autotest hierarchy
  7920. _atActiveLevel = 'testcase'; _atActiveCase = data.caseId;
  7921. const matchApp = _atApps.find(a => (a.cases || []).some(c => c.id === data.caseId));
  7922. if (matchApp) _atActiveApp = matchApp.appId;
  7923. updateAtWfList();
  7924. }
  7925. }
  7926. // ── Workflow completed/failed (legacy broadcast — kept for backward compat, wf_done/wf_error are preferred) ──
  7927. if (data.type === 'workflow_done' && !_workflowActive) {
  7928. // Only handle if wf_done hasn't already processed it
  7929. _setMapIndicator(true);
  7930. }
  7931. if (data.type === 'workflow_error' && !_workflowActive) {
  7932. // Already handled by wf_error
  7933. }
  7934. // ── Rich workflow broadcast events (wf_*) — show step cards from ANY tab ──
  7935. if (data.type === 'wf_start') {
  7936. forwardWorkflowEventToIframe('workflow_start', data);
  7937. _workflowActive = true;
  7938. _lastWorkflowName = data.workflowName || '';
  7939. _lastRunCheckpoint = null;
  7940. for (const k in _stepCards) delete _stepCards[k];
  7941. setChatStatusRunning(true);
  7942. updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
  7943. addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || ''} (${data.stepCount || '?'} steps) [${data.model || ''}]`, null, 'info');
  7944. ensureWorkflowBroadcastChat(data.workflowName || '', { stepCount: data.stepCount || '?', model: data.model || '' });
  7945. // Switch to correct sub-tab based on workflow name
  7946. const wn = data.workflowName || '';
  7947. if (wn.startsWith('autotest')) switchFlowTab('autotest');
  7948. else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
  7949. else switchFlowTab('adjust');
  7950. // Persist running workflow for refresh-restore
  7951. try { localStorage.setItem('vl-code-last-flow-wf', JSON.stringify({ name: wn, tab: currentFlowTab })); } catch {}
  7952. // Keep workflow data refreshed in the background without stealing focus
  7953. if (_lastWorkflowName) loadWorkflowIntoFlowTab(_lastWorkflowName);
  7954. }
  7955. if (data.type === 'wf_node_start') {
  7956. forwardWorkflowEventToIframe('node_start', data);
  7957. const nodeType = data.nodeType || '';
  7958. const nodeTitle = data.title || data.nodeId || '?';
  7959. addStepCard(data.nodeId, nodeType, nodeTitle, data.resolvedInputs || data.input);
  7960. updateChatStatusBar(`Running ${nodeTitle}...`, '');
  7961. updateWorkflowNode(data.nodeId, 'running');
  7962. appendWorkflowBroadcastChatLine(
  7963. `▶ ${nodeType ? '[' + nodeType + '] ' : ''}${nodeTitle}`,
  7964. 'font-size:11px;color:var(--text2);padding:2px 0;',
  7965. `wf-bc-step-${data.nodeId}`
  7966. );
  7967. }
  7968. if (data.type === 'wf_node_done') {
  7969. forwardWorkflowEventToIframe('node_done', data);
  7970. completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
  7971. updateWorkflowNode(data.nodeId, 'done');
  7972. // Update step line in chat
  7973. const chatStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
  7974. if (chatStep) {
  7975. chatStep.style.color = 'var(--green)';
  7976. chatStep.textContent = chatStep.textContent.replace('▶', '✓');
  7977. }
  7978. }
  7979. if (data.type === 'wf_node_error') {
  7980. forwardWorkflowEventToIframe('node_error', data);
  7981. errorStepCard(data.nodeId, data.error || 'Unknown error', data.duration_ms);
  7982. updateWorkflowNode(data.nodeId, 'error');
  7983. // Update step line in chat
  7984. const errStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
  7985. if (errStep) {
  7986. errStep.style.color = 'var(--red)';
  7987. errStep.textContent = errStep.textContent.replace('▶', '✗') + ' — ' + (data.error || '');
  7988. }
  7989. }
  7990. if (data.type === 'wf_node_skipped') {
  7991. forwardWorkflowEventToIframe('node_skipped', data);
  7992. addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
  7993. updateWorkflowNode(data.nodeId, 'skipped');
  7994. }
  7995. if (data.type === 'wf_file_start') {
  7996. addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
  7997. appendWorkflowBroadcastChatLine(`📄 Writing ${data.path || '?'}`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  7998. }
  7999. if (data.type === 'wf_file_done') {
  8000. addDetailEntry('file', `✓ Written: ${data.path || '?'}`, null, 'success', { depth: 1 });
  8001. const runStep = getCurrentRunningStepID();
  8002. if (runStep) addFileToStepCard(runStep, data.path || '?');
  8003. appendWorkflowBroadcastChatLine(`✓ Wrote ${data.path || '?'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8004. }
  8005. if (data.type === 'wf_llm_thinking') {
  8006. appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
  8007. }
  8008. if (data.type === 'wf_llm_tool_use') {
  8009. const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  8010. addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInput, 'info', { depth: 1 });
  8011. updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
  8012. appendWorkflowBroadcastChatLine(`🔧 Tool: ${data.name || 'unknown'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
  8013. }
  8014. if (data.type === 'wf_llm_tool_result') {
  8015. const isErr = data.is_error || false;
  8016. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} Result`, data.content || null, isErr ? 'error' : 'success', { depth: 1 });
  8017. appendWorkflowBroadcastChatLine(
  8018. `${isErr ? '✗' : '✓'} Tool result`,
  8019. `font-size:10px;color:${isErr ? 'var(--red)' : 'var(--green)'};padding:1px 0 1px 14px;`
  8020. );
  8021. }
  8022. if (data.type === 'wf_tool_start') {
  8023. forwardWorkflowEventToIframe('tool_start', data);
  8024. const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  8025. addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
  8026. appendWorkflowBroadcastChatLine(`🛠 Tool step: ${data.name || 'tool'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
  8027. }
  8028. if (data.type === 'wf_tool_done') {
  8029. forwardWorkflowEventToIframe('tool_done', data);
  8030. const toolOutput = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
  8031. addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
  8032. appendWorkflowBroadcastChatLine(`✓ Tool step done: ${data.name || 'tool'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8033. }
  8034. if (data.type === 'wf_tool_error') {
  8035. forwardWorkflowEventToIframe('tool_error', data);
  8036. addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
  8037. appendWorkflowBroadcastChatLine(
  8038. `${data.allowError ? '⚠' : '✗'} Tool step error: ${data.name || 'tool'}`,
  8039. `font-size:10px;color:${data.allowError ? 'var(--orange)' : 'var(--red)'};padding:1px 0 1px 14px;`
  8040. );
  8041. }
  8042. if (data.type === 'wf_tool_message') {
  8043. forwardWorkflowEventToIframe('tool_message', data);
  8044. const toolDetail = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  8045. addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetail, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  8046. appendWorkflowBroadcastChatLine(
  8047. `• ${data.name || 'tool'}: ${data.message || ''}`,
  8048. `font-size:10px;color:${data.level === 'error' ? 'var(--red)' : data.level === 'warn' ? 'var(--orange)' : 'var(--text2)'};padding:1px 0 1px 14px;`
  8049. );
  8050. }
  8051. if (data.type === 'wf_llm_done') {
  8052. flushStreamBoxes();
  8053. const mdl = data.model || '';
  8054. const usg = data.usage || {};
  8055. 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(' | ');
  8056. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  8057. appendWorkflowBroadcastChatLine(`✓ LLM done${parts ? ' — ' + parts : ''}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8058. }
  8059. if (data.type === 'wf_llm_error') {
  8060. addDetailEntry('llm', `✗ LLM Error: ${data.error || 'unknown'}`, data, 'error');
  8061. appendWorkflowBroadcastChatLine(`✗ LLM error: ${data.error || 'unknown'}`, 'font-size:10px;color:var(--red);padding:1px 0 1px 14px;');
  8062. }
  8063. if (data.type === 'wf_var_changed') {
  8064. const vn = data.name || '?';
  8065. const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 80) : '—';
  8066. const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 80) : '—';
  8067. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
  8068. appendWorkflowBroadcastChatLine(`📊 ${vn} updated`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  8069. }
  8070. if (data.type === 'wf_text') {
  8071. addDetailEntry('workflow', data.text || '', null, 'info', { depth: 1 });
  8072. if (data.text) appendWorkflowBroadcastChatLine(data.text, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  8073. }
  8074. if (data.type === 'wf_token') {
  8075. // Streaming LLM tokens from VLGenerate-initiated workflows (broadcast path)
  8076. appendToStreamBox('wf-response-broadcast', '💬 Response', data.token || '');
  8077. }
  8078. if (data.type === 'wf_checkpoint') {
  8079. _lastRunCheckpoint = data.checkpoint || data;
  8080. addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} done)`, null, 'info', { depth: 1 });
  8081. // Forward checkpoint to workflow-editor iframe for re-run support
  8082. sendToWorkflowIframe({ type: 'setCheckpoint', checkpoint: data.checkpoint || data, runID: data.runID, clientRunToken: data.clientRunToken || null });
  8083. }
  8084. if (data.type === 'wf_done') {
  8085. forwardWorkflowEventToIframe('workflow_done', data);
  8086. _workflowActive = false;
  8087. window._skipFlowAutoLoad = false;
  8088. try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
  8089. flushStreamBoxes();
  8090. setChatStatusRunning(false);
  8091. setStatus(`Workflow done: ${data.workflowName || ''}`, 'green');
  8092. addDetailEntry('workflow', `✅ Workflow completed: ${data.workflowName || ''}`, data, 'success');
  8093. _setMapIndicator(true);
  8094. // Show completion in main chat
  8095. if (window._wfBroadcastChatEl) {
  8096. const doneDiv = document.createElement('div');
  8097. doneDiv.style.cssText = 'font-size:11px;color:var(--green);padding:4px 0;font-weight:600;';
  8098. doneDiv.textContent = `✓ Workflow completed. ${data.filesWritten?.length || 0} files written.`;
  8099. window._wfBroadcastChatEl.querySelector('.content-text').appendChild(doneDiv);
  8100. scrollChat();
  8101. window._wfBroadcastChatEl = null;
  8102. } else {
  8103. addMsg('assistant', `**Workflow completed.** ${data.filesWritten?.length || 0} files written.`);
  8104. }
  8105. loadFileTree();
  8106. }
  8107. if (data.type === 'wf_error') {
  8108. forwardWorkflowEventToIframe('workflow_failed', data);
  8109. _workflowActive = false;
  8110. window._skipFlowAutoLoad = false;
  8111. try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
  8112. flushStreamBoxes();
  8113. setChatStatusRunning(false);
  8114. setStatus(`Workflow error: ${data.error || ''}`, 'red');
  8115. addDetailEntry('workflow', `❌ Workflow failed: ${data.workflowName || ''} — ${data.error || ''}`, null, 'error');
  8116. // Show error in main chat
  8117. if (window._wfBroadcastChatEl) {
  8118. const errDiv = document.createElement('div');
  8119. errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:4px 0;font-weight:600;';
  8120. errDiv.textContent = `✗ Workflow error: ${data.error || 'Unknown'}`;
  8121. window._wfBroadcastChatEl.querySelector('.content-text').appendChild(errDiv);
  8122. scrollChat();
  8123. window._wfBroadcastChatEl = null;
  8124. } else {
  8125. addMsg('assistant', `**Workflow error:** ${data.error || 'Unknown'}`);
  8126. }
  8127. }
  8128. // Metadata ready — show green dot on Map tab instead of stealing focus
  8129. if (data.type === 'metadata_ready' && data.meta) {
  8130. _setMapIndicator(true);
  8131. openMetadataTab(data.meta);
  8132. }
  8133. // ── AutoTest SSE events ──
  8134. // PRINCIPLE: Chat shows only high-level milestones (phase start/done, app workflows, case results, summary)
  8135. // Detail panel shows ALL granular info (server logs, LLM data, selectors, timing, node progress)
  8136. if (data.type === 'autotest_progress') {
  8137. const { phase, status, message } = data;
  8138. setStatus(`AutoTest: ${message}`, status === 'error' ? 'red' : status === 'done' ? 'green' : 'yellow');
  8139. if (status === 'running') {
  8140. $('chatStatusBar').style.display = 'flex';
  8141. if (!_chatStartTime) { _chatStartTime = Date.now(); _chatElapsedTimer = setInterval(updateChatElapsed, 1000); }
  8142. if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
  8143. _autotestChatBlock = null;
  8144. window._wfEngineBox = null; // Reset WF engine aggregation box
  8145. window._wfEngineTokens = 0;
  8146. window._wfEngineEvents = 0;
  8147. }
  8148. updateChatStatusBar(status === 'running' ? `Testing ${phase}...` : `Test ${phase} ${status}`, message);
  8149. if (status === 'done' || status === 'error') { if (phase === 'run') setChatStatusRunning(false); }
  8150. // Detail panel: full progress log
  8151. addDetailEntry('autotest', `[${phase}] ${message}`, null, status === 'error' ? 'error' : status === 'done' ? 'success' : 'info');
  8152. // Chat: only show phase milestones (start/done), not every intermediate step
  8153. if (status === 'done' || status === 'error' || (status === 'running' && (phase === 'generate' || phase === 'run'))) {
  8154. const icon = status === 'done' ? '>' : status === 'error' ? 'x' : '...';
  8155. _ensureAutotestChatBlock();
  8156. const stepEl = document.createElement('div');
  8157. 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;' : ''}`;
  8158. stepEl.textContent = `${icon} [${phase}] ${message}`;
  8159. _autotestChatBlock.appendChild(stepEl);
  8160. scrollChat();
  8161. }
  8162. if (status === 'running') {
  8163. _atPipelineStatus = 'running';
  8164. if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
  8165. }
  8166. if (status === 'done' && phase === 'run') _atPipelineStatus = 'done';
  8167. if (status === 'error') _atPipelineStatus = 'error';
  8168. if (phase === 'run' && status === 'running') _atApps.forEach(a => { if (a.status !== 'done' && a.status !== 'error') a.status = 'running'; });
  8169. updateAtWfList();
  8170. }
  8171. // autotest_detail → Detail panel ONLY (server logs, LLM responses, selector info, timing)
  8172. if (data.type === 'autotest_detail') {
  8173. const dType = data.detailType === 'warn' ? 'warn' : data.detailType === 'success' ? 'success' : data.detailType === 'error' ? 'error' : 'info';
  8174. const detailData = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  8175. addDetailEntry('autotest', data.message || '', detailData, dType, { depth: data.phase === 'run' ? 2 : 1 });
  8176. }
  8177. // autotest_case_done → Both panels (it's a milestone)
  8178. if (data.type === 'autotest_case_done') {
  8179. const { caseId, status, current, total } = data;
  8180. const icon = status === 'passed' ? '>' : status === 'soft_pass' ? '~' : 'x';
  8181. const dotStatus = status === 'passed' ? 'done' : status === 'soft_pass' ? 'skipped' : 'error';
  8182. setStatus(`AutoTest: ${current}/${total} ${caseId}`, status === 'passed' ? 'green' : 'red');
  8183. // Detail panel: case result
  8184. addDetailEntry('autotest', `${icon} ${current}/${total} ${caseId} [${status}]`, null, dotStatus === 'done' ? 'success' : 'error', { depth: 1 });
  8185. setAtCaseStatus(caseId, dotStatus);
  8186. // Chat: case result (milestone — not a duplicate since detail shows step-level)
  8187. _ensureAutotestChatBlock();
  8188. const caseEl = document.createElement('div');
  8189. caseEl.style.cssText = `font-size:10px;padding:1px 0 1px 12px;color:${dotStatus === 'done' ? 'var(--green)' : dotStatus === 'skipped' ? '#cc0' : 'var(--red)'};`;
  8190. caseEl.textContent = `${icon} ${current}/${total} ${caseId} [${status}]`;
  8191. _autotestChatBlock.appendChild(caseEl);
  8192. scrollChat();
  8193. }
  8194. // autotest_workflow_saved → Both panels (workflow creation is a milestone)
  8195. if (data.type === 'autotest_workflow_saved') {
  8196. const { name, appId, caseCount, level, cases } = data;
  8197. if (level === 'pipeline') {
  8198. _atPipelineStatus = 'idle';
  8199. addDetailEntry('autotest', `Pipeline workflow saved: ${name}`, null, 'success');
  8200. loadWorkflowIntoFlowTab('autotest-pipeline');
  8201. } else if (level === 'app') {
  8202. const existing = _atApps.find(a => a.appId === appId);
  8203. if (existing) { existing.caseCount = caseCount; existing.name = name; if (cases) existing.cases = cases.map(c => ({ ...c, status: 'idle' })); }
  8204. else _atApps.push({ name, appId, caseCount, status: 'idle', cases: (cases || []).map(c => ({ ...c, status: 'idle' })) });
  8205. if (!_atActiveApp && _atApps.length > 0) _atActiveApp = _atApps[0].appId;
  8206. // Detail: full case list
  8207. 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 });
  8208. // Chat: just workflow name
  8209. _ensureAutotestChatBlock();
  8210. const wfEl = document.createElement('div');
  8211. wfEl.style.cssText = 'font-size:10px;padding:1px 0 1px 12px;color:var(--accent);';
  8212. wfEl.textContent = `+ Workflow: ${name} (${caseCount} test cases)`;
  8213. _autotestChatBlock.appendChild(wfEl);
  8214. scrollChat();
  8215. } else if (level === 'testcase') {
  8216. // Detail only: per-testcase workflow (granular log)
  8217. addDetailEntry('autotest', `Test workflow: ${data.caseName || data.caseId} (${data.stepsCount} steps)`, null, 'info', { depth: 2 });
  8218. }
  8219. updateAtWfList();
  8220. if (currentFlowTab === 'autotest') populateFlowWorkflowSelect();
  8221. }
  8222. // autotest_run_complete → Both panels (final summary)
  8223. if (data.type === 'autotest_run_complete') {
  8224. const { passed, failed, softPassed, total, failures } = data;
  8225. for (const app of _atApps) {
  8226. const hasError = (app.cases || []).some(c => c.status === 'error');
  8227. app.status = hasError ? 'error' : 'done';
  8228. }
  8229. _atPipelineStatus = (failed > 0 || (softPassed || 0) > 0) ? 'error' : 'done';
  8230. updateAtWfList();
  8231. // Detail: full evaluation data
  8232. addDetailEntry('autotest', `Run complete: ${passed} passed, ${failed} failed, ${softPassed || 0} soft-passed / ${total} total`,
  8233. failures?.length ? JSON.stringify(failures.map(f => ({ case: f.caseId, reason: f.reason, softPass: f.softPass })), null, 2) : null,
  8234. failed > 0 ? 'error' : 'success');
  8235. // Chat: summary line
  8236. _ensureAutotestChatBlock();
  8237. const sumEl = document.createElement('div');
  8238. sumEl.style.cssText = 'font-size:11px;font-weight:600;padding:4px 0 0;border-top:1px solid var(--border);margin-top:4px;';
  8239. 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`;
  8240. _autotestChatBlock.appendChild(sumEl);
  8241. scrollChat();
  8242. if (failed > 0 || (softPassed || 0) > 0) showAutotestResultDialog(passed, failed, softPassed || 0, total, failures || []);
  8243. }
  8244. // WF Engine events → Detail panel ONLY — aggregate into a collapsible summary box
  8245. if (data.type === 'autotest_workflow_event') {
  8246. if (!window._wfEngineBox) {
  8247. window._wfEngineTokens = 0;
  8248. window._wfEngineEvents = 0;
  8249. const box = document.createElement('div');
  8250. box.className = 'detail-entry info depth-2';
  8251. box.innerHTML = `
  8252. <span class="de-time">${new Date().toLocaleTimeString()}</span>
  8253. <span class="de-phase">[wf-engine]</span>
  8254. <div class="de-msg" style="cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
  8255. WF Engine stream <span class="wfe-stats" style="color:var(--accent);font-weight:600">0 events, 0 tokens</span> (click to expand)
  8256. </div>
  8257. <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>`;
  8258. const body = $('detailBody');
  8259. body.appendChild(box);
  8260. body.scrollTop = body.scrollHeight;
  8261. window._wfEngineBox = box;
  8262. }
  8263. const evt = data.event || data;
  8264. const evtStr = typeof evt === 'string' ? evt : JSON.stringify(evt);
  8265. window._wfEngineEvents++;
  8266. window._wfEngineTokens += Math.round(evtStr.length / 4); // rough token estimate
  8267. const statsEl = window._wfEngineBox.querySelector('.wfe-stats');
  8268. const tokK = (window._wfEngineTokens / 1000).toFixed(1);
  8269. statsEl.textContent = `${window._wfEngineEvents} events, ~${tokK}K tokens`;
  8270. const logEl = window._wfEngineBox.querySelector('.wfe-log');
  8271. // Only keep last 50 events in DOM to prevent memory bloat
  8272. const lines = logEl.children;
  8273. if (lines.length > 50) logEl.removeChild(lines[0]);
  8274. const line = document.createElement('div');
  8275. line.style.cssText = 'border-bottom:1px solid var(--border);padding:1px 0;';
  8276. // Show summary: node status or truncated content
  8277. const nodeId = evt.nodeId || evt.node_id || '';
  8278. const status = evt.status || evt.type || '';
  8279. const content = evt.content || evt.text || '';
  8280. if (nodeId || status) {
  8281. line.textContent = `[${nodeId}] ${status}${content ? ': ' + content.slice(0, 120) : ''}`;
  8282. } else {
  8283. line.textContent = evtStr.slice(0, 200);
  8284. }
  8285. logEl.appendChild(line);
  8286. }
  8287. } catch {}
  8288. });
  8289. };
  8290. // ===================== WORKFLOW ENGINE SSE (Dragon Broker) =====================
  8291. // Connects to external workflow engine via Dragon Broker SSE endpoint.
  8292. // Spec 3.16 §13.3 events + Engine v0.2.1 extensions.
  8293. // Event order: step_start → llm_thinking → llm_token → llm_tool_use →
  8294. // llm_tool_result → [循环] → llm_done → var_changed → file_done → step_done
  8295. let _wfRunSSE = null; // EventSource for active workflow run
  8296. let _activeRunID = null; // Current workflow run ID
  8297. let _wfRunChatBlock = null; // Chat block for workflow milestones
  8298. let _wfPauseToken = null; // waitToken from pause_start (needed for resume)
  8299. const BROKER_BASE = 'http://localhost:9160';
  8300. function connectWorkflowSSE(runID) {
  8301. disconnectWorkflowSSE();
  8302. _activeRunID = runID;
  8303. _wfRunChatBlock = null;
  8304. clearStreamBoxes();
  8305. const es = new EventSource(`${BROKER_BASE}/workflow/${runID}/events`);
  8306. _wfRunSSE = es;
  8307. // Show status bar
  8308. setChatStatusRunning(true);
  8309. updateChatStatusBar('Workflow starting...', runID);
  8310. addDetailEntry('workflow', `Connected to workflow ${runID}`, null, 'info');
  8311. es.onmessage = (e) => {
  8312. try {
  8313. const data = JSON.parse(e.data);
  8314. _handleWorkflowEngineEvent(data);
  8315. } catch {}
  8316. };
  8317. es.onerror = () => {
  8318. // SSE auto-reconnects; only log once
  8319. if (es.readyState === EventSource.CLOSED) {
  8320. addDetailEntry('workflow', `SSE connection closed for ${runID}`, null, 'warn');
  8321. }
  8322. };
  8323. }
  8324. function disconnectWorkflowSSE() {
  8325. if (_wfRunSSE) {
  8326. try { _wfRunSSE.close(); } catch {}
  8327. _wfRunSSE = null;
  8328. }
  8329. _activeRunID = null;
  8330. _wfRunChatBlock = null;
  8331. }
  8332. function _ensureWfChatBlock() {
  8333. if (_wfRunChatBlock) return;
  8334. const container = $('chatMessages');
  8335. const block = document.createElement('div');
  8336. block.className = 'msg assistant';
  8337. block.style.position = 'relative';
  8338. const now = formatMsgTime(new Date());
  8339. 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>`;
  8340. container.appendChild(block);
  8341. _wfRunChatBlock = block.querySelector('.wf-chat-body');
  8342. scrollChat();
  8343. }
  8344. function _handleWorkflowEngineEvent(raw) {
  8345. // SSE format per Spec §13.2: { run_id, seq, ts, type, step_id, payload }
  8346. // Normalize: payload fields may be top-level (Engine) or nested in payload (Spec)
  8347. const payload = raw.payload || raw;
  8348. const type = raw.type || raw.event;
  8349. const stepID = raw.stepID || raw.step_id || payload.stepId || payload.nodeId || null;
  8350. // ── workflow_start → Status Bar + Detail Log + Chat ──
  8351. if (type === 'workflow_start') {
  8352. const name = payload.name || '';
  8353. _lastWorkflowName = name;
  8354. _lastRunCheckpoint = null;
  8355. for (const k in _stepCards) delete _stepCards[k];
  8356. addDetailEntry('workflow', `▶ Workflow started${name ? ': ' + name : ''}${payload.resumedFrom ? ' (resumed from ' + payload.resumedFrom + ')' : ''}`, payload.params || null, 'info');
  8357. updateChatStatusBar(`Running: ${name || _activeRunID}`, '');
  8358. _ensureWfChatBlock();
  8359. const startEl = document.createElement('div');
  8360. startEl.style.cssText = 'color:var(--accent);font-weight:600;padding:2px 0;';
  8361. startEl.textContent = `▶ Workflow: ${name || _activeRunID}${payload.resumedFrom ? ' (from ' + payload.resumedFrom + ')' : ''}`;
  8362. _wfRunChatBlock.appendChild(startEl);
  8363. scrollChat();
  8364. return;
  8365. }
  8366. // ── LLM thinking tokens → Detail Log (stream box, collapsible) ──
  8367. if (type === 'llm_thinking') {
  8368. appendToStreamBox(`wf-thinking-${stepID || _activeRunID}`, '💭 Thinking', payload.delta || payload.chunk || '');
  8369. return;
  8370. }
  8371. // ── LLM response tokens → Detail Log (stream box) ──
  8372. if (type === 'llm_token') {
  8373. appendToStreamBox(`wf-response-${stepID || _activeRunID}`, '💬 Response', payload.delta || payload.chunk || '');
  8374. return;
  8375. }
  8376. // ── LLM tool use → Detail Log (collapsible) ──
  8377. if (type === 'llm_tool_use') {
  8378. const toolName = payload.name || payload.tool_name || 'unknown';
  8379. const toolInput = payload.input || payload.params || {};
  8380. addDetailEntry('tool-call', `🔧 ${toolName}`, toolInput, 'info', { depth: 1 });
  8381. updateChatStatusBar(`Tool: ${toolName}`, '');
  8382. return;
  8383. }
  8384. // ── LLM tool result → Detail Log (collapsible) ──
  8385. if (type === 'llm_tool_result') {
  8386. const isError = payload.is_error || false;
  8387. const content = payload.content || payload.result || '';
  8388. const toolId = payload.tool_use_id || '';
  8389. addDetailEntry('tool-result', `${isError ? '✗' : '✓'} Result${toolId ? ' [' + toolId.slice(-8) + ']' : ''}`, content, isError ? 'error' : 'success', { depth: 1 });
  8390. return;
  8391. }
  8392. if (type === 'tool_start') {
  8393. const toolName = payload.name || stepID || 'tool';
  8394. addDetailEntry('tool-call', `🛠 ${toolName}`, payload.input || null, 'info', { depth: 1 });
  8395. updateChatStatusBar(`Tool step: ${toolName}`, '');
  8396. return;
  8397. }
  8398. if (type === 'tool_done') {
  8399. const toolName = payload.name || stepID || 'tool';
  8400. addDetailEntry('tool-result', `✓ ${toolName}`, payload.output || null, 'success', { depth: 1 });
  8401. return;
  8402. }
  8403. if (type === 'tool_error') {
  8404. const toolName = payload.name || stepID || 'tool';
  8405. addDetailEntry('tool-result', `✗ ${toolName}${payload.allowError ? ' (continued)' : ''}`, payload.error || null, payload.allowError ? 'warn' : 'error', { depth: 1 });
  8406. return;
  8407. }
  8408. if (type === 'tool_message') {
  8409. const toolName = payload.name || stepID || 'tool';
  8410. addDetailEntry('tool-call', `• ${toolName}: ${payload.message || ''}`, payload.data || null, payload.level === 'error' ? 'error' : payload.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  8411. return;
  8412. }
  8413. // ── LLM done → Detail Log (summary) ──
  8414. if (type === 'llm_done') {
  8415. flushStreamBoxes();
  8416. const model = payload.model || '';
  8417. const tokens = payload.usage || {};
  8418. const latency = payload.latency_ms ? `${(payload.latency_ms / 1000).toFixed(1)}s` : '';
  8419. const summary = [model, tokens.input_tokens ? `in:${tokens.input_tokens}` : '', tokens.output_tokens ? `out:${tokens.output_tokens}` : '', latency].filter(Boolean).join(' | ');
  8420. addDetailEntry('llm', `✓ LLM complete — ${summary}`, null, 'success');
  8421. return;
  8422. }
  8423. // ── LLM error → Main Chat + Detail Log ──
  8424. if (type === 'llm_error') {
  8425. const err = payload.error || {};
  8426. const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Unknown LLM error';
  8427. const retryable = (payload.retryable || err.retryable) ? ' (retryable)' : '';
  8428. addDetailEntry('llm', `✗ LLM Error${retryable}: ${errMsg}`, payload, 'error');
  8429. _ensureWfChatBlock();
  8430. const errEl = document.createElement('div');
  8431. errEl.style.cssText = 'color:var(--red);font-weight:600;padding:2px 0;';
  8432. errEl.textContent = `✗ LLM Error${retryable}: ${errMsg}`;
  8433. _wfRunChatBlock.appendChild(errEl);
  8434. scrollChat();
  8435. return;
  8436. }
  8437. // ── Variable changed → Detail Log ──
  8438. if (type === 'var_changed') {
  8439. const varName = payload.name || payload.variable || '?';
  8440. const oldVal = payload.oldValue != null ? JSON.stringify(payload.oldValue).slice(0, 80) : (payload.old != null ? JSON.stringify(payload.old).slice(0, 80) : '—');
  8441. const newVal = payload.newValue != null ? JSON.stringify(payload.newValue).slice(0, 80) : (payload.new != null ? JSON.stringify(payload.new).slice(0, 80) : '—');
  8442. addDetailEntry('var', `📊 ${varName}: ${oldVal} → ${newVal}`, payload, 'info', { depth: 1 });
  8443. return;
  8444. }
  8445. // ── Step start → Status Bar + Step Card + Chat + DAG ──
  8446. if (type === 'step_start') {
  8447. const stepType = payload.type || payload.step_type || payload.stepType || '';
  8448. const stepTitle = payload.meta?.title || stepID || '?';
  8449. // Use enhanced step card
  8450. addStepCard(stepID, stepType, stepTitle, payload.resolvedInputs);
  8451. updateChatStatusBar(`Running ${stepTitle}...`, stepType);
  8452. updateWorkflowNode(stepID, 'running');
  8453. clearStreamBoxes();
  8454. // Chat: show step progress
  8455. _ensureWfChatBlock();
  8456. const stepEl = document.createElement('div');
  8457. stepEl.id = `wf-step-${stepID}`;
  8458. stepEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text2);';
  8459. stepEl.textContent = ` ▶ ${stepTitle}${stepType ? ' [' + stepType + ']' : ''}`;
  8460. _wfRunChatBlock.appendChild(stepEl);
  8461. scrollChat();
  8462. return;
  8463. }
  8464. // ── Step done → Step Card + Chat + DAG ──
  8465. if (type === 'step_done') {
  8466. flushStreamBoxes();
  8467. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8468. // Complete step card with outputs
  8469. completeStepCard(stepID, payload.outputs, payload.selected, payload.duration_ms);
  8470. updateWorkflowNode(stepID, 'done');
  8471. // Chat: update step line to show done
  8472. const chatStepEl = document.getElementById(`wf-step-${stepID}`);
  8473. if (chatStepEl) {
  8474. chatStepEl.style.color = 'var(--green)';
  8475. chatStepEl.textContent = ` ✓ ${stepID || '?'} done${duration}`;
  8476. }
  8477. return;
  8478. }
  8479. // ── Step error → Step Card + Main Chat + DAG ──
  8480. if (type === 'step_error') {
  8481. flushStreamBoxes();
  8482. const err = payload.error || {};
  8483. const errMsg = (typeof err === 'string' ? err : err.message) || 'Step error';
  8484. // Error step card with re-run button
  8485. errorStepCard(stepID, errMsg, payload.duration_ms);
  8486. updateWorkflowNode(stepID, 'error');
  8487. // Show in chat — step errors are actionable
  8488. _ensureWfChatBlock();
  8489. const errEl = document.createElement('div');
  8490. errEl.style.cssText = 'color:var(--red);padding:2px 0;';
  8491. errEl.textContent = `✗ Step ${stepID || '?'}: ${errMsg}`;
  8492. _wfRunChatBlock.appendChild(errEl);
  8493. scrollChat();
  8494. return;
  8495. }
  8496. // ── Step skipped → Detail Log + DAG ──
  8497. if (type === 'step_skipped') {
  8498. const reason = payload.reason || 'if_false';
  8499. addDetailEntry('step', `⊘ ${stepID || '?'} skipped (${reason})`, null, 'info', { depth: 1 });
  8500. updateWorkflowNode(stepID, 'skipped');
  8501. return;
  8502. }
  8503. // ── Step print → Detail Log + Chat ──
  8504. if (type === 'step_print') {
  8505. const msg = payload.message || payload.value || '';
  8506. addDetailEntry('print', `📝 ${stepID || ''}: ${msg}`, null, 'info', { depth: 1 });
  8507. // Print messages are user-facing — show in chat
  8508. _ensureWfChatBlock();
  8509. const printEl = document.createElement('div');
  8510. printEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text);';
  8511. printEl.textContent = ` 📝 ${msg}`;
  8512. _wfRunChatBlock.appendChild(printEl);
  8513. scrollChat();
  8514. return;
  8515. }
  8516. // ── File start → Detail Log ──
  8517. if (type === 'file_start') {
  8518. const path = payload.path || '?';
  8519. addDetailEntry('file', `📄 Writing: ${path}`, null, 'info', { depth: 1 });
  8520. return;
  8521. }
  8522. // ── File done → Detail Log + Step Card + Chat + File Tree ──
  8523. if (type === 'file_done') {
  8524. const filePath = payload.path || '?';
  8525. const size = payload.size_bytes != null ? ` (${payload.size_bytes > 1024 ? (payload.size_bytes / 1024).toFixed(1) + 'KB' : payload.size_bytes + 'B'})` : '';
  8526. addDetailEntry('file', `✓ Written: ${filePath}${size}`, null, 'success', { depth: 1 });
  8527. // Associate with running step card
  8528. const runStep = getCurrentRunningStepID();
  8529. if (runStep) addFileToStepCard(runStep, filePath);
  8530. // Trigger file tree refresh
  8531. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  8532. window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
  8533. // Chat: show file written
  8534. _ensureWfChatBlock();
  8535. const fileEl = document.createElement('div');
  8536. fileEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--green);';
  8537. fileEl.textContent = ` 📄 ${filePath}${size}`;
  8538. _wfRunChatBlock.appendChild(fileEl);
  8539. scrollChat();
  8540. return;
  8541. }
  8542. // ── Pause start → Main Chat (approval buttons) ──
  8543. if (type === 'pause_start') {
  8544. const reason = payload.reason || 'Workflow paused — awaiting approval';
  8545. _wfPauseToken = payload.waitToken || null;
  8546. addDetailEntry('pause', `⏸ Paused: ${reason}`, payload, 'warn');
  8547. updateChatStatusBar('Paused', reason);
  8548. _ensureWfChatBlock();
  8549. const pauseDiv = document.createElement('div');
  8550. pauseDiv.className = 'wf-pause-block';
  8551. pauseDiv.style.cssText = 'padding:6px 0;border-top:1px solid var(--border);margin-top:4px;';
  8552. pauseDiv.innerHTML = `
  8553. <div style="color:var(--orange);font-weight:600;margin-bottom:4px;">⏸ ${escapeHtml(reason)}</div>
  8554. <div style="display:flex;gap:6px;">
  8555. <button onclick="resumeBrokerWorkflow()" style="background:var(--green);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">▶ Resume</button>
  8556. <button onclick="abortWorkflow()" style="background:var(--red);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">✗ Abort</button>
  8557. </div>`;
  8558. _wfRunChatBlock.appendChild(pauseDiv);
  8559. scrollChat();
  8560. return;
  8561. }
  8562. // ── Pause resumed → Detail Log + update chat ──
  8563. if (type === 'pause_resumed') {
  8564. _wfPauseToken = null;
  8565. addDetailEntry('pause', `▶ Resumed (req: ${payload.requestId || '—'})`, null, 'success');
  8566. updateChatStatusBar('Resumed', '');
  8567. // Disable pause buttons
  8568. const btns = document.querySelectorAll('.wf-pause-block button');
  8569. btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
  8570. return;
  8571. }
  8572. // ── Pause timeout → Detail Log + Main Chat ──
  8573. if (type === 'pause_timeout') {
  8574. _wfPauseToken = null;
  8575. const action = payload.timeoutAction || '';
  8576. addDetailEntry('pause', `⏰ Pause timed out → ${action}`, payload, 'warn');
  8577. _ensureWfChatBlock();
  8578. const toEl = document.createElement('div');
  8579. toEl.style.cssText = 'color:var(--orange);padding:2px 0;';
  8580. toEl.textContent = `⏰ Pause timed out${action ? ' → ' + action : ''}`;
  8581. _wfRunChatBlock.appendChild(toEl);
  8582. scrollChat();
  8583. // Disable pause buttons
  8584. const btns = document.querySelectorAll('.wf-pause-block button');
  8585. btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
  8586. return;
  8587. }
  8588. // ── Pause rejected → Detail Log ──
  8589. if (type === 'pause_rejected') {
  8590. const reasonCode = payload.reasonCode || 'unknown';
  8591. addDetailEntry('pause', `✗ Resume rejected: ${reasonCode}`, payload, 'error');
  8592. return;
  8593. }
  8594. // ── Workflow done → Main Chat + Status Bar ──
  8595. if (type === 'workflow_done') {
  8596. flushStreamBoxes();
  8597. const stopId = payload.stop_id || '';
  8598. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8599. const summary = `Workflow completed${duration}${stopId ? ' at ' + stopId : ''}`;
  8600. addDetailEntry('workflow', `✓ ${summary}`, payload, 'success');
  8601. setChatStatusRunning(false);
  8602. setStatus('Workflow done', 'green');
  8603. _ensureWfChatBlock();
  8604. const doneEl = document.createElement('div');
  8605. doneEl.style.cssText = 'color:var(--green);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8606. doneEl.textContent = `✓ ${summary}`;
  8607. _wfRunChatBlock.appendChild(doneEl);
  8608. scrollChat();
  8609. disconnectWorkflowSSE();
  8610. return;
  8611. }
  8612. // ── Workflow failed → Main Chat + Status Bar ──
  8613. if (type === 'workflow_failed') {
  8614. flushStreamBoxes();
  8615. const err = payload.error || {};
  8616. const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Workflow failed';
  8617. const failedStep = payload.failed_step_id || '';
  8618. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8619. addDetailEntry('workflow', `✗ ${errMsg}${failedStep ? ' at ' + failedStep : ''}${duration}`, payload, 'error');
  8620. setChatStatusRunning(false);
  8621. setStatus('Workflow failed', 'red');
  8622. _ensureWfChatBlock();
  8623. const failEl = document.createElement('div');
  8624. failEl.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8625. failEl.textContent = `✗ ${errMsg}${failedStep ? ' (step: ' + failedStep + ')' : ''}`;
  8626. _wfRunChatBlock.appendChild(failEl);
  8627. scrollChat();
  8628. disconnectWorkflowSSE();
  8629. return;
  8630. }
  8631. // ── Workflow cancelled → Main Chat + Status Bar ──
  8632. if (type === 'workflow_cancelled') {
  8633. flushStreamBoxes();
  8634. const reason = payload.reason || 'cancelled';
  8635. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8636. addDetailEntry('workflow', `⊘ Cancelled: ${reason}${duration}`, payload, 'warn');
  8637. setChatStatusRunning(false);
  8638. setStatus('Workflow cancelled', 'orange');
  8639. _ensureWfChatBlock();
  8640. const cancelEl = document.createElement('div');
  8641. cancelEl.style.cssText = 'color:var(--orange);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8642. cancelEl.textContent = `⊘ Workflow cancelled: ${reason}`;
  8643. _wfRunChatBlock.appendChild(cancelEl);
  8644. scrollChat();
  8645. disconnectWorkflowSSE();
  8646. return;
  8647. }
  8648. }
  8649. // ── Workflow control actions ──
  8650. // Resume per Spec §11.4: { runId, token, payload, requestId }
  8651. async function resumeBrokerWorkflow(resumePayload) {
  8652. const runID = _activeRunID;
  8653. if (!runID) return;
  8654. try {
  8655. const body = {
  8656. runId: runID,
  8657. token: _wfPauseToken || '',
  8658. payload: resumePayload || {},
  8659. requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  8660. };
  8661. const res = await fetch(`${BROKER_BASE}/workflow/${runID}/resume`, {
  8662. method: 'POST',
  8663. headers: { 'Content-Type': 'application/json' },
  8664. body: JSON.stringify(body)
  8665. });
  8666. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8667. addDetailEntry('workflow', `▶ Resume requested (req: ${body.requestId})`, null, 'info');
  8668. updateChatStatusBar('Resuming...', '');
  8669. } catch (e) {
  8670. addDetailEntry('workflow', `Resume failed: ${e.message}`, null, 'error');
  8671. }
  8672. }
  8673. async function abortWorkflow() {
  8674. const runID = _activeRunID;
  8675. if (!runID) return;
  8676. try {
  8677. const res = await fetch(`${BROKER_BASE}/workflow/${runID}/abort`, { method: 'POST' });
  8678. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8679. addDetailEntry('workflow', '✗ Abort requested', null, 'warn');
  8680. updateChatStatusBar('Aborting...', '');
  8681. } catch (e) {
  8682. addDetailEntry('workflow', `Abort failed: ${e.message}`, null, 'error');
  8683. }
  8684. }
  8685. async function fetchWorkflowSnapshot(runID) {
  8686. try {
  8687. const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/status`);
  8688. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8689. return await res.json();
  8690. } catch (e) {
  8691. addDetailEntry('workflow', `Snapshot fetch failed: ${e.message}`, null, 'error');
  8692. return null;
  8693. }
  8694. }
  8695. async function fetchWorkflowVariables(runID) {
  8696. try {
  8697. const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/variables`);
  8698. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8699. return await res.json();
  8700. } catch (e) {
  8701. return null;
  8702. }
  8703. }
  8704. // ===================== WORKFLOW RE-RUN DIALOG =====================
  8705. async function openRerunDialog(stepID) {
  8706. // Fetch checkpoint — try local API first, then broker
  8707. let checkpoint = null;
  8708. try {
  8709. // Try local API (for workflows run through /api/workflow/execute)
  8710. const localRes = await fetch('/api/workflow/variables');
  8711. if (localRes.ok) {
  8712. // Use the stored checkpoint from last run
  8713. const cpRes = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
  8714. if (cpRes.ok) checkpoint = await cpRes.json();
  8715. }
  8716. } catch {}
  8717. if (!checkpoint && _activeRunID) {
  8718. try {
  8719. const res = await fetch(`${BROKER_BASE}/workflow/${_activeRunID}/checkpoint`);
  8720. if (res.ok) checkpoint = await res.json();
  8721. } catch {}
  8722. }
  8723. if (!checkpoint && _lastRunCheckpoint) {
  8724. // Use the last checkpoint received via SSE
  8725. checkpoint = _lastRunCheckpoint;
  8726. }
  8727. // Build the dialog
  8728. const vars = checkpoint?.variables || {};
  8729. let varRows = '';
  8730. for (const [k, v] of Object.entries(vars)) {
  8731. const valStr = typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '');
  8732. const isLong = valStr.length > 200;
  8733. varRows += `
  8734. <div class="rr-var-row">
  8735. <div class="rr-var-name">${escapeHtml(k)}</div>
  8736. <textarea class="rr-var-val" data-var="${escapeHtml(k)}" rows="${isLong ? 6 : 2}">${escapeHtml(isLong ? valStr.substring(0, 2000) : valStr)}</textarea>
  8737. </div>`;
  8738. }
  8739. const dialog = document.createElement('div');
  8740. dialog.className = 'modal-overlay open';
  8741. dialog.id = 'rerunDialog';
  8742. dialog.onclick = (e) => { if (e.target === dialog) dialog.remove(); };
  8743. dialog.innerHTML = `
  8744. <div class="modal-box" style="max-width:600px;max-height:80vh;overflow-y:auto;">
  8745. <h3 style="margin:0 0 12px;font-size:14px;color:var(--accent);">🔄 Re-run from: ${escapeHtml(stepID)}</h3>
  8746. <div style="font-size:10px;color:var(--text2);margin-bottom:8px;">
  8747. Workflow: ${escapeHtml(_lastWorkflowName || 'unknown')}<br>
  8748. Steps before this one will NOT re-execute.<br>
  8749. You can edit variables below before re-running.
  8750. </div>
  8751. <div style="font-size:11px;font-weight:600;margin:8px 0 4px;color:var(--text);">Pipeline Variables:</div>
  8752. <div class="rr-vars" style="max-height:300px;overflow-y:auto;">
  8753. ${varRows || '<div style="color:var(--text2);font-size:10px;">No variables available</div>'}
  8754. </div>
  8755. <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;">
  8756. <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>
  8757. <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>
  8758. </div>
  8759. </div>`;
  8760. document.body.appendChild(dialog);
  8761. }
  8762. async function executeRerun(stepID) {
  8763. const dialog = document.getElementById('rerunDialog');
  8764. if (!dialog) return;
  8765. // Collect overrides from edited variables
  8766. const overrides = {};
  8767. const textareas = dialog.querySelectorAll('.rr-var-val');
  8768. for (const ta of textareas) {
  8769. const varName = ta.dataset.var;
  8770. const val = ta.value.trim();
  8771. try {
  8772. // Try to parse as JSON
  8773. overrides[varName] = JSON.parse(val);
  8774. } catch {
  8775. overrides[varName] = val;
  8776. }
  8777. }
  8778. dialog.remove();
  8779. // Fetch checkpoint
  8780. let checkpoint = _lastRunCheckpoint;
  8781. if (!checkpoint) {
  8782. try {
  8783. const res = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
  8784. if (res.ok) checkpoint = await res.json();
  8785. } catch {}
  8786. }
  8787. if (!checkpoint) {
  8788. addDetailEntry('workflow', '✗ Cannot re-run: no checkpoint available', null, 'error');
  8789. return;
  8790. }
  8791. addDetailEntry('workflow', `🔄 Re-running from ${stepID} with ${Object.keys(overrides).length} variable(s)`, null, 'info');
  8792. // Clear step cards for fresh display
  8793. for (const k in _stepCards) delete _stepCards[k];
  8794. // Call rerun API (SSE stream)
  8795. try {
  8796. const response = await fetch('/api/workflow/rerun', {
  8797. method: 'POST',
  8798. headers: { 'Content-Type': 'application/json' },
  8799. body: JSON.stringify({
  8800. workflowName: _lastWorkflowName,
  8801. checkpoint,
  8802. stepID,
  8803. overrides,
  8804. }),
  8805. });
  8806. if (!response.body) {
  8807. addDetailEntry('workflow', '✗ Re-run failed: no response body', null, 'error');
  8808. return;
  8809. }
  8810. // Process SSE stream (same as sendMessage workflow handling)
  8811. setChatStatusRunning(true);
  8812. updateChatStatusBar(`Re-running from ${stepID}...`, '');
  8813. const reader = response.body.getReader();
  8814. const decoder = new TextDecoder();
  8815. let buffer = '';
  8816. let currentEvent = '';
  8817. while (true) {
  8818. const { done, value } = await reader.read();
  8819. if (done) break;
  8820. buffer += decoder.decode(value, { stream: true });
  8821. const lines = buffer.split('\n');
  8822. buffer = lines.pop() || '';
  8823. for (const line of lines) {
  8824. if (line.startsWith('event: ')) {
  8825. currentEvent = line.slice(7).trim();
  8826. } else if (line.startsWith('data: ')) {
  8827. try {
  8828. const data = JSON.parse(line.slice(6));
  8829. // Dispatch to the same workflow event handler
  8830. const raw = { type: currentEvent, payload: data, stepID: data.stepId || data.nodeId };
  8831. _handleWorkflowEngineEvent(raw);
  8832. } catch {}
  8833. }
  8834. }
  8835. }
  8836. setChatStatusRunning(false);
  8837. } catch (e) {
  8838. addDetailEntry('workflow', `✗ Re-run failed: ${e.message}`, null, 'error');
  8839. setChatStatusRunning(false);
  8840. }
  8841. }
  8842. // ===================== SYNTAX HIGHLIGHTING =====================
  8843. function highlightCode(code, ext) {
  8844. const lines = code.split('\n');
  8845. const isVL = ['vx','sc','cp','vs','vdb','vth'].includes(ext);
  8846. const isJson = ext === 'json';
  8847. return lines.map(line => {
  8848. let html = escapeHtml(line);
  8849. if (isVL) {
  8850. html = highlightVL(html);
  8851. } else if (isJson) {
  8852. html = highlightJSON(html);
  8853. }
  8854. return `<span class="line">${html}</span>`;
  8855. }).join('\n');
  8856. }
  8857. function highlightVL(line) {
  8858. // Comments
  8859. if (/^\s*\/\//.test(line)) return `<span class="cmt">${line}</span>`;
  8860. // Keywords
  8861. 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>');
  8862. // Tags like <Section-X> <Component-Y> <ServiceDomain-Z>
  8863. line = line.replace(/(&lt;)([\w-]+)(\/?\s*&gt;|[^&]*&gt;)/g, '<span class="tag">$1$2$3</span>');
  8864. // $variables
  8865. line = line.replace(/(\$\w+)/g, '<span class="var">$1</span>');
  8866. // @events
  8867. line = line.replace(/(@\w+)/g, '<span class="evt">$1</span>');
  8868. // Strings
  8869. line = line.replace(/(&quot;[^&]*&quot;|&#39;[^&]*&#39;|"[^"]*"|'[^']*')/g, '<span class="str">$1</span>');
  8870. // Numbers
  8871. line = line.replace(/\b(\d+\.?\d*)\b/g, '<span class="num">$1</span>');
  8872. return line;
  8873. }
  8874. function highlightJSON(line) {
  8875. // Property keys
  8876. line = line.replace(/(&quot;)([^&]+)(&quot;)\s*:/g, '<span class="prop">$1$2$3</span>:');
  8877. // String values
  8878. line = line.replace(/:\s*(&quot;)([^&]*)(&quot;)/g, ': <span class="str">$1$2$3</span>');
  8879. // Numbers
  8880. line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="num">$1</span>');
  8881. // Booleans / null
  8882. line = line.replace(/:\s*(true|false|null)\b/g, ': <span class="kw">$1</span>');
  8883. return line;
  8884. }
  8885. // renderMarkdown is defined earlier in the file (search for "Simple markdown → HTML renderer")
  8886. // ===================== FLOW EDITOR TOOLBAR =====================
  8887. let currentFlowTab = 'generate'; // 'generate' | 'adjust' | 'autotest'
  8888. // ── AutoTest 3-layer state ──
  8889. let _atApps = []; // [{name, appId, caseCount, status, cases:[{id,name,status}]}]
  8890. let _atPipelineStatus = 'idle';
  8891. let _atActiveLevel = 'pipeline';
  8892. let _atActiveApp = '';
  8893. let _atActiveCase = '';
  8894. /** Load existing autotest app workflows from disk on init */
  8895. async function loadAtAppsFromWorkflows() {
  8896. try {
  8897. // Try loading saved test cases first (has full case info)
  8898. const casesRes = await fetch('/api/file/raw?path=.vl-code/autotest-cases.json');
  8899. if (casesRes.ok) {
  8900. const rawText = await casesRes.text();
  8901. if (rawText) {
  8902. const parsed = JSON.parse(rawText);
  8903. const testCases = parsed.testCases || [];
  8904. if (testCases.length > 0) {
  8905. const appGroups = {};
  8906. for (const tc of testCases) {
  8907. const appId = tc.appId || 'App';
  8908. if (!appGroups[appId]) appGroups[appId] = [];
  8909. appGroups[appId].push({ id: tc.id, name: tc.name || tc.id, status: 'idle', priority: tc.priority, stepsCount: tc.steps?.length || 0 });
  8910. }
  8911. _atApps = [];
  8912. for (const [appId, cases] of Object.entries(appGroups)) {
  8913. const safeName = `autotest-${appId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  8914. _atApps.push({ name: safeName, appId, caseCount: cases.length, status: 'idle', cases });
  8915. }
  8916. if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
  8917. // Restore test results (pass/fail status) from saved results
  8918. await _restoreAtResults();
  8919. updateAtWfList();
  8920. return;
  8921. }
  8922. }
  8923. }
  8924. // Fallback: scan workflow files
  8925. const data = await api('/api/workflows');
  8926. const wfs = (data.workflows || []).filter(w => w.name.startsWith('autotest-') && !w.name.startsWith('autotest-tc-') && w.name !== 'autotest-pipeline');
  8927. if (wfs.length === 0) return;
  8928. _atApps = [];
  8929. for (const wf of wfs) {
  8930. const appId = wf.name.replace('autotest-', '');
  8931. try {
  8932. const wfData = await api(`/api/workflow/${wf.name}`);
  8933. const steps = wfData.workflow?.steps || [];
  8934. const cases = steps.filter(s => s.id && s.meta?.title).map(s => ({
  8935. id: s.id, name: s.meta?.title || s.id, status: 'idle'
  8936. }));
  8937. _atApps.push({ name: wf.name, appId, caseCount: cases.length || wf.stepCount, status: 'idle', cases });
  8938. } catch {
  8939. _atApps.push({ name: wf.name, appId, caseCount: wf.stepCount, status: 'idle', cases: [] });
  8940. }
  8941. }
  8942. if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
  8943. await _restoreAtResults();
  8944. updateAtWfList();
  8945. } catch {}
  8946. }
  8947. /** Restore pass/fail status from saved autotest-results.json */
  8948. async function _restoreAtResults() {
  8949. try {
  8950. const res = await fetch('/api/file/raw?path=.vl-code/autotest-results.json');
  8951. if (!res.ok) return;
  8952. const rawText = await res.text();
  8953. if (!rawText) return;
  8954. const results = JSON.parse(rawText);
  8955. const evals = results.evaluations || [];
  8956. if (evals.length === 0) return;
  8957. // Map caseId → status
  8958. const statusMap = {};
  8959. for (const ev of evals) {
  8960. const id = ev.caseId || ev.id;
  8961. if (!id) continue;
  8962. if (ev.evaluation?.pass) statusMap[id] = ev.evaluation?.softPass ? 'soft' : 'done';
  8963. else statusMap[id] = 'error';
  8964. }
  8965. // Apply to _atApps
  8966. let anyResult = false;
  8967. for (const app of _atApps) {
  8968. let appHasError = false, appAllDone = true;
  8969. for (const tc of (app.cases || [])) {
  8970. if (statusMap[tc.id]) { tc.status = statusMap[tc.id]; anyResult = true; }
  8971. if (tc.status === 'error') appHasError = true;
  8972. if (tc.status === 'idle') appAllDone = false;
  8973. }
  8974. if (anyResult) app.status = appHasError ? 'error' : appAllDone ? 'done' : 'idle';
  8975. }
  8976. if (anyResult) {
  8977. const hasError = _atApps.some(a => a.status === 'error');
  8978. _atPipelineStatus = hasError ? 'error' : 'done';
  8979. }
  8980. } catch {}
  8981. }
  8982. function switchFlowTab(tab) {
  8983. currentFlowTab = tab;
  8984. document.querySelectorAll('.flow-sub-tab').forEach(t => t.classList.toggle('active', t.dataset.flow === tab));
  8985. populateFlowWorkflowSelect();
  8986. updateFlowWfList();
  8987. if (tab === 'autotest') {
  8988. // Restore saved test cases from disk if not already loaded
  8989. if (_atApps.length === 0) loadAtAppsFromWorkflows();
  8990. updateAtWfList();
  8991. loadActiveFlowWorkflow();
  8992. } else {
  8993. $('atWfList').classList.remove('visible');
  8994. loadActiveFlowWorkflow();
  8995. }
  8996. }
  8997. /** Render the workflow picker bar for Generate / Adjust sub-tabs */
  8998. function updateFlowWfList() {
  8999. const list = $('flowWfList');
  9000. if (currentFlowTab === 'autotest') { list.classList.remove('visible'); return; }
  9001. list.classList.add('visible');
  9002. const workflows = currentFlowTab === 'generate' ? CODEGEN_WORKFLOWS : ADJUST_WORKFLOWS;
  9003. const sel = $('flowWfSelect');
  9004. const currentFile = sel.value || (currentFlowTab === 'generate'
  9005. ? (workflowBindings.generate || 'parallel-codegen')
  9006. : (workflowBindings.adjust || 'incremental-update'));
  9007. list.innerHTML = '';
  9008. for (const [key, info] of Object.entries(workflows)) {
  9009. const isActive = currentFile === info.file || currentFile === key;
  9010. const div = document.createElement('div');
  9011. div.className = 'flow-wf-item' + (isActive ? ' active' : '');
  9012. div.innerHTML = `<span class="fwi-name">${escapeHtml(info.label)}</span><span class="fwi-desc">${escapeHtml(info.desc)}</span>`;
  9013. div.onclick = () => {
  9014. _setFlowWfSelectOrStore(info.file, sel);
  9015. loadFlowWorkflow(info.file);
  9016. if (currentFlowTab === 'generate') selectCodegenWorkflow(key);
  9017. list.querySelectorAll('.flow-wf-item').forEach(el => el.classList.remove('active'));
  9018. div.classList.add('active');
  9019. };
  9020. list.appendChild(div);
  9021. }
  9022. }
  9023. async function populateFlowWorkflowSelect() {
  9024. const sel = $('flowWfSelect');
  9025. sel.innerHTML = '<option value="">-- Select Workflow --</option>';
  9026. try {
  9027. const data = await api('/api/workflows');
  9028. for (const wf of (data.workflows || [])) {
  9029. const isAutotest = wf.name.startsWith('autotest-');
  9030. if (currentFlowTab === 'autotest' && !isAutotest) continue;
  9031. if (currentFlowTab !== 'autotest' && isAutotest) continue;
  9032. const isDefault = (currentFlowTab === 'generate' && wf.name === (workflowBindings.generate || '3-file-codegen'))
  9033. || (currentFlowTab === 'adjust' && wf.name === (workflowBindings.adjust || 'incremental-update'))
  9034. || (currentFlowTab === 'autotest' && wf.name === (workflowBindings.autotest || 'autotest-pipeline'));
  9035. sel.innerHTML += `<option value="${escapeHtml(wf.name)}"${isDefault ? ' selected' : ''}>${escapeHtml(wf.title || wf.name)} (${wf.stepCount} steps)</option>`;
  9036. }
  9037. } catch {}
  9038. }
  9039. function loadActiveFlowWorkflow() {
  9040. // Skip if a tool (VLGenerate/WorkflowRun) is loading a workflow directly
  9041. if (window._skipFlowAutoLoad) return;
  9042. const sel = $('flowWfSelect');
  9043. const defaults = { generate: workflowBindings.generate || '3-file-codegen', adjust: workflowBindings.adjust || 'incremental-update', autotest: workflowBindings.autotest || 'autotest-pipeline' };
  9044. const name = sel.value || defaults[currentFlowTab] || '';
  9045. if (name) loadFlowWorkflow(name);
  9046. }
  9047. // ── AutoTest 3-layer hierarchy UI ──
  9048. function updateAtWfList() {
  9049. const list = $('atWfList');
  9050. if (currentFlowTab !== 'autotest') { list.classList.remove('visible'); return; }
  9051. list.classList.add('visible');
  9052. if (_atApps.length === 0) {
  9053. if (_atPipelineStatus === 'running') {
  9054. 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>';
  9055. } else {
  9056. list.innerHTML = '<div style="padding:6px 10px;color:var(--text2);font-size:10px;">No test workflows yet. Run autotest-pipeline to generate.</div>';
  9057. }
  9058. return;
  9059. }
  9060. let html = `<div class="at-wf-pipeline${_atActiveLevel === 'pipeline' ? ' active' : ''}" onclick="selectAtLevel('pipeline')">
  9061. <span class="at-wf-dot ${_atPipelineStatus}"></span>
  9062. <span>Pipeline Overview</span>
  9063. <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>
  9064. </div>`;
  9065. html += '<div class="at-wf-apps">';
  9066. for (const app of _atApps) {
  9067. const isActive = _atActiveLevel === 'app' && _atActiveApp === app.appId;
  9068. html += `<div class="at-wf-app${isActive ? ' active' : ''}" onclick="selectAtLevel('app','${escapeHtml(app.appId)}')">
  9069. <span class="at-wf-dot ${app.status || 'idle'}"></span>
  9070. <span>${escapeHtml(app.appId)}</span><span class="app-count">(${app.caseCount})</span>
  9071. </div>`;
  9072. }
  9073. html += '</div>';
  9074. const selApp = _atApps.find(a => a.appId === _atActiveApp);
  9075. if (selApp && selApp.cases && selApp.cases.length > 0) {
  9076. html += '<div class="at-wf-cases">';
  9077. for (const tc of selApp.cases) {
  9078. const isActive = _atActiveLevel === 'testcase' && _atActiveCase === tc.id;
  9079. const label = tc.name ? (tc.name.length > 28 ? tc.name.slice(0, 26) + '…' : tc.name) : tc.id;
  9080. const stepsInfo = tc.stepsCount ? `${tc.stepsCount} steps` : '';
  9081. 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>` : '';
  9082. html += `<div class="at-wf-case${isActive ? ' active' : ''}" onclick="selectAtLevel('testcase','${escapeHtml(selApp.appId)}','${escapeHtml(tc.id)}')" title="${escapeHtml(tc.name || tc.id)}">
  9083. <span class="at-wf-dot ${tc.status || 'idle'}"></span><span>${escapeHtml(label)}</span>${prioTag}
  9084. <span style="margin-left:auto;font-size:9px;color:var(--text2);">${stepsInfo}</span>
  9085. </div>`;
  9086. }
  9087. html += '</div>';
  9088. }
  9089. list.innerHTML = html;
  9090. }
  9091. function selectAtLevel(level, appId, caseId) {
  9092. _atActiveLevel = level;
  9093. const sel = $('flowWfSelect');
  9094. if (level === 'pipeline') {
  9095. _atActiveApp = ''; _atActiveCase = '';
  9096. _setFlowWfSelectOrStore('autotest-pipeline', sel);
  9097. loadFlowWorkflow('autotest-pipeline');
  9098. } else if (level === 'app') {
  9099. _atActiveApp = appId || ''; _atActiveCase = '';
  9100. const app = _atApps.find(a => a.appId === appId);
  9101. if (app) { _setFlowWfSelectOrStore(app.name, sel); loadFlowWorkflow(app.name); }
  9102. } else if (level === 'testcase') {
  9103. _atActiveApp = appId || ''; _atActiveCase = caseId || '';
  9104. const wfName = `autotest-tc-${(caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  9105. _setFlowWfSelectOrStore(wfName, sel);
  9106. loadFlowWorkflow(wfName);
  9107. }
  9108. updateAtWfList();
  9109. }
  9110. /** Set flowWfSelect to name if option exists, otherwise store in data attr for runFlowWorkflow */
  9111. function _setFlowWfSelectOrStore(name, sel) {
  9112. const opt = [...sel.options].find(o => o.value === name);
  9113. if (opt) { sel.value = name; }
  9114. else { sel.dataset.pendingRun = name; }
  9115. }
  9116. // Ensure a live-updating autotest chat block exists for streaming progress
  9117. let _autotestChatBlock = null;
  9118. function _ensureAutotestChatBlock() {
  9119. if (_autotestChatBlock && _autotestChatBlock.isConnected) return;
  9120. const container = $('chatMessages');
  9121. const block = document.createElement('div');
  9122. block.className = 'msg system autotest-live-block';
  9123. 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;';
  9124. block.innerHTML = '<div style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px;">AutoTest Pipeline</div>';
  9125. container.appendChild(block);
  9126. _autotestChatBlock = block;
  9127. }
  9128. // Load a named workflow into the Flow DAG iframe
  9129. async function loadWorkflowIntoFlowTab(wfName) {
  9130. try {
  9131. await loadFlowWorkflow(wfName);
  9132. } catch {}
  9133. }
  9134. function setAtAppStatus(appId, status) {
  9135. const app = _atApps.find(a => a.appId === appId);
  9136. if (app) { app.status = status; updateAtWfList(); }
  9137. }
  9138. function setAtCaseStatus(caseId, status) {
  9139. for (const app of _atApps) {
  9140. const tc = (app.cases || []).find(c => c.id === caseId);
  9141. if (tc) { tc.status = status; updateAtWfList(); break; }
  9142. }
  9143. }
  9144. // ── AutoTest Result Dialog ──
  9145. function showAutotestResultDialog(passed, failed, softPassed, total, failures) {
  9146. $('autotestResultSummary').innerHTML = `<div style="display:flex;gap:16px;font-size:14px;font-weight:600;">
  9147. <span style="color:var(--green);">✅ ${passed} passed</span>
  9148. ${softPassed > 0 ? `<span style="color:#cc0;">⚠️ ${softPassed} soft-passed</span>` : ''}
  9149. <span style="color:var(--red);">❌ ${failed} failed</span>
  9150. <span style="color:var(--text2);">/ ${total} total</span>
  9151. </div>`;
  9152. const failHtml = (failures || []).map(f =>
  9153. `<div style="margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:4px;">
  9154. <strong>${escapeHtml(f.name || f.caseId)}</strong><br>
  9155. <span style="color:var(--red);">${escapeHtml(f.reason || 'Unknown')}</span>
  9156. </div>`).join('');
  9157. $('autotestResultFailures').innerHTML = failHtml || '<em>No failure details</em>';
  9158. $('autotestResultModal').classList.add('open');
  9159. }
  9160. function autotestAction(action) {
  9161. $('autotestResultModal').classList.remove('open');
  9162. if (action === 'fix') {
  9163. $('chatInput').value = '/test-fix';
  9164. sendMessage();
  9165. } else if (action === 'report') {
  9166. // Direct AutoTestPipeline report call — reliable, no LLM drift
  9167. $('chatInput').value = 'Call AutoTestPipeline with action "report" and display the result.';
  9168. sendMessage();
  9169. } else if (action === 'skip') {
  9170. // Abort current session so any running fix/debug loop stops
  9171. fetch('/api/abort', {
  9172. method: 'POST',
  9173. headers: { 'Content-Type': 'application/json' },
  9174. body: JSON.stringify({ chatId: activeConvId }),
  9175. }).catch(() => {});
  9176. }
  9177. }
  9178. function onFlowWfSelectChange(val) {
  9179. loadFlowWorkflow(val);
  9180. // Persist selection to server for multi-window sync
  9181. try { fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flowWorkflow: val, flowTab: currentFlowTab }) }); } catch {}
  9182. }
  9183. async function loadFlowWorkflow(name) {
  9184. if (!name) return;
  9185. try {
  9186. const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
  9187. // API returns workflow JSON directly (has .steps), or wrapped in .workflow
  9188. const wf = data.workflow || (data.steps ? data : null);
  9189. if (wf) {
  9190. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9191. return { type: 'loadWorkflow', data: wf, workflowName: name };
  9192. });
  9193. }
  9194. } catch { setStatus('Failed to load workflow', 'red'); }
  9195. }
  9196. function importFlowJson() { $('flowJsonInput').click(); }
  9197. $('flowJsonInput').addEventListener('change', (e) => {
  9198. const file = e.target.files[0];
  9199. if (!file) return;
  9200. const reader = new FileReader();
  9201. reader.onload = () => {
  9202. try {
  9203. const json = JSON.parse(reader.result);
  9204. const name = file.name.replace('.json', '');
  9205. // Show in the DAG viewer
  9206. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9207. return { type: 'loadWorkflow', data: json, workflowName: name };
  9208. });
  9209. // Also save to server
  9210. fetch(`/api/workflow/${encodeURIComponent(name)}`, {
  9211. method: 'POST',
  9212. headers: { 'Content-Type': 'application/json' },
  9213. body: JSON.stringify(json),
  9214. }).then(() => {
  9215. populateFlowWorkflowSelect();
  9216. setStatus(`Loaded workflow: ${name}`, 'green');
  9217. }).catch(() => {});
  9218. } catch { setStatus('Invalid JSON file', 'red'); }
  9219. };
  9220. reader.readAsText(file);
  9221. e.target.value = '';
  9222. });
  9223. // Drag-drop workflow JSON onto flow editor area
  9224. document.addEventListener('dragover', (e) => {
  9225. if (currentMode === 'flow') e.preventDefault();
  9226. });
  9227. document.addEventListener('drop', (e) => {
  9228. if (currentMode !== 'flow') return;
  9229. e.preventDefault();
  9230. const file = e.dataTransfer.files[0];
  9231. if (!file || !file.name.endsWith('.json')) return;
  9232. const reader = new FileReader();
  9233. reader.onload = () => {
  9234. try {
  9235. const json = JSON.parse(reader.result);
  9236. const name = file.name.replace('.json', '');
  9237. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9238. return { type: 'loadWorkflow', data: json, workflowName: name };
  9239. });
  9240. fetch(`/api/workflow/${encodeURIComponent(name)}`, {
  9241. method: 'POST',
  9242. headers: { 'Content-Type': 'application/json' },
  9243. body: JSON.stringify(json),
  9244. }).then(() => {
  9245. populateFlowWorkflowSelect();
  9246. setStatus(`Dropped workflow: ${name}`, 'green');
  9247. }).catch(() => {});
  9248. } catch { setStatus('Invalid JSON file', 'red'); }
  9249. };
  9250. reader.readAsText(file);
  9251. });
  9252. // ===================== RUN WORKFLOW =====================
  9253. async function runFlowWorkflow() {
  9254. const sel = $('flowWfSelect');
  9255. const wfName = sel.value || sel.dataset.pendingRun || '';
  9256. if (!wfName) { setStatus('Select a workflow first', 'yellow'); return; }
  9257. if (flowRunning) return;
  9258. // Autotest tab: run tests directly via AutoTestPipeline tool (not chat)
  9259. // This gives us real-time node highlighting + step-by-step progress via SSE
  9260. if (currentFlowTab === 'autotest') {
  9261. const isCase = wfName.startsWith('autotest-tc-');
  9262. const isPipeline = wfName === 'autotest-pipeline';
  9263. const isApp = wfName.startsWith('autotest-') && !isCase && !isPipeline;
  9264. // Determine which case IDs to run
  9265. let caseIds = null; // null = run all
  9266. if (isCase) {
  9267. caseIds = [wfName.replace('autotest-tc-', '')];
  9268. } else if (isApp) {
  9269. const appId = wfName.replace('autotest-', '');
  9270. const app = _atApps.find(a => a.appId === appId);
  9271. if (app?.cases) caseIds = app.cases.map(c => c.id);
  9272. }
  9273. await runAutotestDirect(caseIds);
  9274. return;
  9275. }
  9276. // Generate / Adjust tab: prompt for description (null = user pressed Cancel → abort)
  9277. const userRequest = prompt('Describe what to generate or modify (leave blank to use defaults):', '');
  9278. if (userRequest === null) return;
  9279. flowRunning = true;
  9280. const btn = $('flowRunBtn');
  9281. const statusEl = $('flowRunStatus');
  9282. btn.disabled = true;
  9283. btn.classList.add('running');
  9284. btn.innerHTML = '&#9881; Running...';
  9285. statusEl.textContent = 'Starting...';
  9286. setStatus('Workflow running...', 'yellow');
  9287. // Clear previous node statuses in DAG iframe
  9288. sendToWorkflowIframe({ type: 'clearStatus' });
  9289. let filesCount = 0;
  9290. let lastError = null;
  9291. try {
  9292. const res = await fetch('/api/workflow/execute', {
  9293. method: 'POST',
  9294. headers: { 'Content-Type': 'application/json' },
  9295. body: JSON.stringify({ workflowName: wfName, params: { userRequest, targetLang: 'en' } }),
  9296. });
  9297. const reader = res.body.getReader();
  9298. const decoder = new TextDecoder();
  9299. let buffer = '';
  9300. while (true) {
  9301. const { done, value } = await reader.read();
  9302. if (done) break;
  9303. buffer += decoder.decode(value, { stream: true });
  9304. // Parse SSE: "event: type\ndata: json\n\n"
  9305. const blocks = buffer.split('\n\n');
  9306. buffer = blocks.pop(); // keep incomplete block
  9307. for (const block of blocks) {
  9308. let evtType = 'message', evtData = null;
  9309. for (const line of block.split('\n')) {
  9310. if (line.startsWith('event: ')) evtType = line.slice(7).trim();
  9311. else if (line.startsWith('data: ')) {
  9312. try { evtData = JSON.parse(line.slice(6)); } catch {}
  9313. }
  9314. }
  9315. if (!evtData) continue;
  9316. switch (evtType) {
  9317. case 'node_start':
  9318. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'running', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9319. statusEl.textContent = evtData.title || evtData.nodeId;
  9320. break;
  9321. case 'node_done':
  9322. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'done', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9323. break;
  9324. case 'node_error':
  9325. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'error', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9326. lastError = evtData.error;
  9327. break;
  9328. case 'screenshot':
  9329. debugLog('screenshot', evtData);
  9330. break;
  9331. case 'file_written':
  9332. filesCount++;
  9333. statusEl.textContent = `${filesCount} files written`;
  9334. break;
  9335. case 'done': {
  9336. const realFiles = (evtData.filesWritten || []).filter(p => p && p !== '/');
  9337. const realCount = realFiles.length || filesCount;
  9338. if (realCount === 0) {
  9339. lastError = 'Workflow completed but no source files were written';
  9340. statusEl.textContent = 'Done (0 files — check metadata has filePath fields)';
  9341. } else {
  9342. statusEl.textContent = `Done! ${realCount} files`;
  9343. }
  9344. break;
  9345. }
  9346. case 'error':
  9347. lastError = evtData.message;
  9348. statusEl.textContent = 'Error: ' + (evtData.message || '').slice(0, 60);
  9349. break;
  9350. }
  9351. }
  9352. }
  9353. } catch (e) {
  9354. lastError = e.message;
  9355. statusEl.textContent = 'Error';
  9356. }
  9357. // Reset button state
  9358. flowRunning = false;
  9359. btn.disabled = false;
  9360. btn.classList.remove('running');
  9361. btn.innerHTML = '&#9654; Run';
  9362. await loadFileTree();
  9363. setStatus(lastError ? 'Workflow finished with errors' : `Done: ${filesCount} files generated`, lastError ? 'red' : 'green');
  9364. }
  9365. /**
  9366. * Run autotest directly via AutoTestPipeline tool (not through chat).
  9367. * Gives us SSE events for real-time DAG node highlighting + step progress.
  9368. * @param {string[]|null} caseIds - null = run all, array = run specific cases
  9369. */
  9370. async function runAutotestDirect(caseIds) {
  9371. const btn = $('flowRunBtn');
  9372. const statusEl = $('flowRunStatus');
  9373. const selectedWorkflow = $('flowWfSelect').value || workflowBindings.autotest || 'autotest-pipeline';
  9374. flowRunning = true;
  9375. btn.disabled = true;
  9376. btn.classList.add('running');
  9377. btn.innerHTML = '&#9881; Running...';
  9378. statusEl.textContent = caseIds ? `Running ${caseIds.length} test(s)...` : 'Running all tests...';
  9379. setStatus('AutoTest running...', 'yellow');
  9380. // Reset case statuses to running
  9381. _atPipelineStatus = selectedWorkflow === 'autotest-pipeline' ? 'running' : _atPipelineStatus;
  9382. for (const app of _atApps) {
  9383. for (const tc of (app.cases || [])) {
  9384. if (!caseIds || caseIds.includes(tc.id)) tc.status = 'running';
  9385. }
  9386. app.status = 'running';
  9387. }
  9388. updateAtWfList();
  9389. // Clear DAG node statuses
  9390. sendToWorkflowIframe({ type: 'clearStatus' });
  9391. // Open detail panel for live progress
  9392. if (!$('detailPanel').classList.contains('open')) toggleDetailPanel();
  9393. try {
  9394. const toolInput = { action: selectedWorkflow === 'autotest-pipeline' ? 'full' : 'run' };
  9395. if (caseIds) toolInput.caseIds = caseIds;
  9396. const res = await fetch('/api/tools/execute', {
  9397. method: 'POST',
  9398. headers: { 'Content-Type': 'application/json' },
  9399. body: JSON.stringify({ name: 'AutoTestPipeline', input: toolInput }),
  9400. });
  9401. const data = await res.json();
  9402. if (data.error) {
  9403. statusEl.textContent = 'Error: ' + (data.error || '').slice(0, 80);
  9404. setStatus('AutoTest failed', 'red');
  9405. } else {
  9406. const summaryText = typeof data.result === 'string'
  9407. ? data.result
  9408. : (data.result?.result || data.result || 'Done');
  9409. statusEl.textContent = String(summaryText).slice(0, 120);
  9410. setStatus('AutoTest complete', 'green');
  9411. }
  9412. } catch (e) {
  9413. statusEl.textContent = 'Error: ' + e.message;
  9414. setStatus('AutoTest failed', 'red');
  9415. }
  9416. flowRunning = false;
  9417. btn.disabled = false;
  9418. btn.classList.remove('running');
  9419. btn.innerHTML = '&#9654; Run';
  9420. // Refresh state from saved results
  9421. await loadAtAppsFromWorkflows();
  9422. }
  9423. /** Send a postMessage to the workflow-editor iframe */
  9424. function sendToWorkflowIframe(msg) {
  9425. const container = $('iframeContainer');
  9426. const iframe = container?.querySelector('iframe[data-tab="__mode_workflow__"]');
  9427. if (iframe?.contentWindow) iframe.contentWindow.postMessage(msg, '*');
  9428. }
  9429. function forwardWorkflowEventToIframe(type, payload) {
  9430. sendToWorkflowIframe({ type: 'workflowEvent', event: { ...(payload || {}), type } });
  9431. }
  9432. // ===================== EXPORT ZIP =====================
  9433. // Import files into current project (adds to file tree)
  9434. async function importFiles() { loadFolder(); }
  9435. // Create new project from ZIP: extract to parent/newdir with helper files
  9436. async function importZipAsProject() { importZip(); }
  9437. // Export all files (VL + Process/ + .vl-code/ + helpers)
  9438. async function exportAll() {
  9439. setStatus('Exporting all files...', 'yellow');
  9440. try {
  9441. const res = await fetch('/api/export-zip?scope=all');
  9442. if (!res.ok) { setStatus('Export failed', 'red'); return; }
  9443. const blob = await res.blob();
  9444. const url = URL.createObjectURL(blob);
  9445. const a = document.createElement('a');
  9446. a.href = url;
  9447. a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '.zip';
  9448. document.body.appendChild(a);
  9449. a.click();
  9450. a.remove();
  9451. URL.revokeObjectURL(url);
  9452. setStatus('Exported all files', 'green');
  9453. } catch { setStatus('Export failed', 'red'); }
  9454. }
  9455. // Export VL files only (.vx, .sc, .cp, .vs, .vdb, .vth)
  9456. async function exportVLOnly() {
  9457. setStatus('Exporting VL files...', 'yellow');
  9458. try {
  9459. const res = await fetch('/api/export-zip?scope=vl');
  9460. if (!res.ok) { setStatus('Export failed', 'red'); return; }
  9461. const blob = await res.blob();
  9462. const url = URL.createObjectURL(blob);
  9463. const a = document.createElement('a');
  9464. a.href = url;
  9465. a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '_VL.zip';
  9466. document.body.appendChild(a);
  9467. a.click();
  9468. a.remove();
  9469. URL.revokeObjectURL(url);
  9470. setStatus('Exported VL files', 'green');
  9471. } catch { setStatus('Export failed', 'red'); }
  9472. }
  9473. // Legacy alias
  9474. async function exportZip() { return exportAll(); }
  9475. // ===================== COMPILE & PREVIEW =====================
  9476. async function compileProject() {
  9477. // Guard: check if current workspace is a VL project
  9478. try {
  9479. const proj = await api('/api/project');
  9480. if (!proj.isVL) {
  9481. setStatus('Cannot compile — no VL files in workspace', 'red');
  9482. addMsg('assistant', '**Cannot compile:** Current workspace has no VL source files (.vx, .sc, .cp, .vs, .vdb). Please switch to a VL project workspace first.');
  9483. return;
  9484. }
  9485. } catch {}
  9486. const btn = $('compileBtn');
  9487. btn.disabled = true;
  9488. btn.innerHTML = '&#9203; Compiling...';
  9489. btn.style.opacity = '0.6';
  9490. setStatus('Compiling project...', 'yellow');
  9491. addMsg('assistant', 'Compiling project... Uploading VL files to cloud platform.');
  9492. addDetailEntry('compile', 'Compile started — packaging VL files', null, 'info');
  9493. const compileStart = Date.now();
  9494. try {
  9495. // Step 1: Push files to cloud (if we have a GID)
  9496. let gid = $('cloudGid').value.trim();
  9497. if (!gid) {
  9498. // Try reading from project config
  9499. try {
  9500. const cfgRes = await fetch('/api/file?path=Config/ProjectConfig');
  9501. if (cfgRes.ok) { const d = await cfgRes.json(); gid = (d.content || '').trim(); }
  9502. } catch {}
  9503. if (!gid) {
  9504. try {
  9505. const profile = normalizeProjectProfile(await api('/api/profile'));
  9506. gid = getProfileGid(profile);
  9507. } catch {}
  9508. }
  9509. }
  9510. if (gid) {
  9511. addDetailEntry('compile', `Pushing files to cloud (GID: ${gid})...`, null, 'info');
  9512. try {
  9513. const pushRes = await fetch('/api/cloud/sync/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gid }) });
  9514. const pushData = await pushRes.json();
  9515. if (pushData.error) addDetailEntry('compile', 'Push warning: ' + pushData.error, null, 'warn');
  9516. else addDetailEntry('compile', `Pushed ${pushData.pushed || pushData.total || 0} files`, null, 'success');
  9517. } catch (pushErr) {
  9518. addDetailEntry('compile', 'Push failed: ' + pushErr.message, null, 'warn');
  9519. }
  9520. }
  9521. // Step 2: Compile via VLCompile tool (returns JSON)
  9522. const res = await fetch('/api/cloud/compile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetGid: gid ? Number(gid) : undefined }) });
  9523. if (res.status === 401) {
  9524. setStatus('Login required to compile', 'yellow');
  9525. addMsg('assistant', 'Compile requires cloud login. Opening login dialog...');
  9526. addDetailEntry('compile', 'Not authenticated — opening login', null, 'warn');
  9527. openCloudLogin();
  9528. return;
  9529. }
  9530. const elapsed = ((Date.now() - compileStart) / 1000).toFixed(1);
  9531. const raw = await res.text();
  9532. let data;
  9533. try { data = JSON.parse(raw); } catch { data = { error: raw.substring(0, 200) }; }
  9534. // VLCompile returns result as JSON string inside "result" field
  9535. if (typeof data === 'string') { try { data = JSON.parse(data); } catch {} }
  9536. if (data.result && typeof data.result === 'string') { try { data = JSON.parse(data.result); } catch { data = { error: data.result }; } }
  9537. if (data.error && !data.success) {
  9538. setStatus('Compile failed: ' + data.error, 'red');
  9539. addMsg('assistant', `**Compile failed** (${elapsed}s): ${data.error}`);
  9540. addDetailEntry('compile', 'Compile failed: ' + data.error, null, 'error');
  9541. return;
  9542. }
  9543. // Track GID for reuse — persist to Config/ProjectConfig so next compile uses same project
  9544. if (data.gid) {
  9545. $('cloudGid').value = data.gid;
  9546. fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) }).catch(() => {});
  9547. }
  9548. // Show results
  9549. const urls = data.previewUrls || {};
  9550. const keys = Object.keys(urls);
  9551. const errList = data.errList || [];
  9552. const errCount = data.errCount || errList.length;
  9553. addDetailEntry('compile', `Compile response (${elapsed}s) — GID: ${data.gid || gid || 'none'}, errors: ${errCount}`, null, errCount > 0 ? 'warn' : 'success');
  9554. if (keys.length > 0) {
  9555. activatePreview(urls);
  9556. const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
  9557. const summary = errCount > 0
  9558. ? `**Compile completed** in ${elapsed}s — ${errCount} error(s), ${keys.length} app(s)`
  9559. : `**Compile success** in ${elapsed}s — ${keys.length} app(s) ready`;
  9560. addMsg('assistant', `${summary} (GID: ${data.gid || gid})\n\n**Preview URLs:**\n${urlList}`);
  9561. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled — preview ready', errCount > 0 ? 'yellow' : 'green');
  9562. } else if (!data.gid && !gid) {
  9563. // Syntax check only — Lambda ran without a cloud workspace
  9564. setStatus(errCount > 0 ? `Syntax errors (${errCount})` : 'Syntax OK — no cloud project', errCount > 0 ? 'yellow' : 'yellow');
  9565. 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.`);
  9566. } else {
  9567. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled (no preview URLs)', errCount > 0 ? 'yellow' : 'green');
  9568. addMsg('assistant', `**Compile done** in ${elapsed}s (GID: ${data.gid || gid}) — ${errCount} error(s).`);
  9569. }
  9570. if (errCount > 0 && errList.length > 0) {
  9571. const errLines = errList.map((e, i) => {
  9572. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  9573. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  9574. return ` ${i + 1}. ${JSON.stringify(e)}`;
  9575. }).join('\n');
  9576. addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
  9577. addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
  9578. }
  9579. } catch (e) {
  9580. setStatus('Compile error', 'red');
  9581. addMsg('assistant', `**Compile error:** ${e.message}`);
  9582. } finally {
  9583. btn.disabled = false;
  9584. btn.innerHTML = '&#9654; Compile';
  9585. btn.style.opacity = '1';
  9586. }
  9587. }
  9588. autoResizeChatInput(true);
  9589. init();
  9590. </script>
  9591. </body>
  9592. </html>