@hienlh/ppm 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/bun.lock +3 -0
  3. package/dist/web/assets/api-client-ANLU-Irq.js +1 -0
  4. package/dist/web/assets/chat-tab-C6iTYbRI.js +7 -0
  5. package/dist/web/assets/code-editor-hnDDc8JZ.js +1 -0
  6. package/dist/web/assets/{diff-viewer-B9oX4DDx.js → diff-viewer-BWeMVAvK.js} +1 -1
  7. package/dist/web/assets/git-graph-D6oftHHC.js +1 -0
  8. package/dist/web/assets/index-CWwJBtaO.js +21 -0
  9. package/dist/web/assets/index-jmj5f_bQ.css +2 -0
  10. package/dist/web/assets/{input-AESbQWjx.js → input-D-F4ITU0.js} +1 -1
  11. package/dist/web/assets/jsx-runtime-B4BJKQ1u.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-DdDDhQDx.js → markdown-renderer-PHBaNQ3l.js} +2 -2
  13. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  14. package/dist/web/assets/rotate-ccw-BesidNnx.js +1 -0
  15. package/dist/web/assets/settings-store-CGtTcr8r.js +1 -0
  16. package/dist/web/assets/settings-tab-BpETyigv.js +1 -0
  17. package/dist/web/assets/tab-store-Dq1kMOkJ.js +1 -0
  18. package/dist/web/assets/{terminal-tab-BeFf07MH.js → terminal-tab-BTumEYyO.js} +2 -2
  19. package/dist/web/assets/{use-monaco-theme-Bb9W0CI2.js → use-monaco-theme-CsNwoeyj.js} +1 -1
  20. package/dist/web/index.html +8 -7
  21. package/dist/web/sw.js +1 -1
  22. package/package.json +2 -1
  23. package/src/cli/commands/stop.ts +8 -0
  24. package/src/providers/claude-agent-sdk.ts +36 -13
  25. package/src/server/index.ts +15 -0
  26. package/src/server/routes/chat.ts +31 -3
  27. package/src/server/ws/chat.ts +40 -0
  28. package/src/services/claude-usage.service.ts +51 -23
  29. package/src/services/tunnel.service.ts +4 -0
  30. package/src/types/api.ts +1 -0
  31. package/src/types/chat.ts +1 -0
  32. package/src/web/app.tsx +5 -0
  33. package/src/web/components/chat/chat-history-bar.tsx +15 -3
  34. package/src/web/components/chat/chat-tab.tsx +45 -50
  35. package/src/web/components/chat/message-input.tsx +116 -55
  36. package/src/web/components/chat/message-list.tsx +156 -69
  37. package/src/web/components/chat/usage-badge.tsx +4 -4
  38. package/src/web/components/layout/command-palette.tsx +37 -8
  39. package/src/web/components/layout/draggable-tab.tsx +4 -4
  40. package/src/web/components/layout/mobile-drawer.tsx +2 -2
  41. package/src/web/components/layout/mobile-nav.tsx +3 -2
  42. package/src/web/components/layout/project-bar.tsx +5 -3
  43. package/src/web/components/layout/project-bottom-sheet.tsx +3 -1
  44. package/src/web/components/layout/tab-bar.tsx +4 -4
  45. package/src/web/components/shared/bug-report-popup.tsx +58 -0
  46. package/src/web/hooks/use-chat.ts +63 -7
  47. package/src/web/hooks/use-usage.ts +15 -17
  48. package/src/web/lib/report-bug.ts +12 -3
  49. package/src/web/stores/project-store.ts +7 -1
  50. package/vite.config.ts +2 -0
  51. package/dist/web/assets/api-client-BsHoRDAn.js +0 -1
  52. package/dist/web/assets/chat-tab-Bj1hZQ4x.js +0 -6
  53. package/dist/web/assets/code-editor-Bj9jdnLm.js +0 -1
  54. package/dist/web/assets/copy-BNk4Z75P.js +0 -1
  55. package/dist/web/assets/external-link-CrtbmtJ6.js +0 -1
  56. package/dist/web/assets/git-graph-DoLRBTMk.js +0 -1
  57. package/dist/web/assets/index-C_yeSRZ0.css +0 -2
  58. package/dist/web/assets/index-D27GI6gs.js +0 -21
  59. package/dist/web/assets/jsx-runtime-BFALxl05.js +0 -1
  60. package/dist/web/assets/settings-store-DWYkr_a3.js +0 -1
  61. package/dist/web/assets/settings-tab-BLoiK6Nc.js +0 -1
  62. package/dist/web/assets/tab-store-B1wzyDLQ.js +0 -1
package/dist/web/sw.js CHANGED
@@ -1 +1 @@
1
- try{self[`workbox:core:7.3.0`]&&_()}catch{}var e=(e,...t)=>{let n=e;return t.length>0&&(n+=` :: ${JSON.stringify(t)}`),n},t=class extends Error{constructor(t,n){let r=e(t,n);super(r),this.name=t,this.details=n}},n={googleAnalytics:`googleAnalytics`,precache:`precache-v2`,prefix:`workbox`,runtime:`runtime`,suffix:typeof registration<`u`?registration.scope:``},r=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join(`-`),i=e=>{for(let t of Object.keys(n))e(t)},a={updateDetails:e=>{i(t=>{typeof e[t]==`string`&&(n[t]=e[t])})},getGoogleAnalyticsName:e=>e||r(n.googleAnalytics),getPrecacheName:e=>e||r(n.precache),getPrefix:()=>n.prefix,getRuntimeName:e=>e||r(n.runtime),getSuffix:()=>n.suffix};function o(e,t){let n=t();return e.waitUntil(n),n}try{self[`workbox:precaching:7.3.0`]&&_()}catch{}var s=`__WB_REVISION__`;function c(e){if(!e)throw new t(`add-to-cache-list-unexpected-type`,{entry:e});if(typeof e==`string`){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:n,url:r}=e;if(!r)throw new t(`add-to-cache-list-unexpected-type`,{entry:e});if(!n){let e=new URL(r,location.href);return{cacheKey:e.href,url:e.href}}let i=new URL(r,location.href),a=new URL(r,location.href);return i.searchParams.set(s,n),{cacheKey:i.href,url:a.href}}var l=class{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:n})=>{if(e.type===`install`&&t&&t.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;n?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return n}}},u=class{constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:e,params:t})=>{let n=t?.cacheKey||this._precacheController.getCacheKeyForURL(e.url);return n?new Request(n,{headers:e.headers}):e},this._precacheController=e}},d;function f(){if(d===void 0){let e=new Response(``);if(`body`in e)try{new Response(e.body),d=!0}catch{d=!1}d=!1}return d}async function p(e,n){let r=null;if(e.url&&(r=new URL(e.url).origin),r!==self.location.origin)throw new t(`cross-origin-copy-response`,{origin:r});let i=e.clone(),a={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},o=n?n(a):a,s=f()?i.body:await i.blob();return new Response(s,o)}var m=e=>new URL(String(e),location.href).href.replace(RegExp(`^${location.origin}`),``);function h(e,t){let n=new URL(e);for(let e of t)n.searchParams.delete(e);return n.href}async function g(e,t,n,r){let i=h(t.url,n);if(t.url===i)return e.match(t,r);let a=Object.assign(Object.assign({},r),{ignoreSearch:!0}),o=await e.keys(t,a);for(let t of o)if(i===h(t.url,n))return e.match(t,r)}var v=class{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}},y=new Set;async function b(){for(let e of y)await e()}function x(e){return new Promise(t=>setTimeout(t,e))}try{self[`workbox:strategies:7.3.0`]&&_()}catch{}function S(e){return typeof e==`string`?new Request(e):e}var C=class{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new v,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(let e of this._plugins)this._pluginStateMap.set(e,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:n}=this,r=S(e);if(r.mode===`navigate`&&n instanceof FetchEvent&&n.preloadResponse){let e=await n.preloadResponse;if(e)return e}let i=this.hasCallback(`fetchDidFail`)?r.clone():null;try{for(let e of this.iterateCallbacks(`requestWillFetch`))r=await e({request:r.clone(),event:n})}catch(e){if(e instanceof Error)throw new t(`plugin-error-request-will-fetch`,{thrownErrorMessage:e.message})}let a=r.clone();try{let e;e=await fetch(r,r.mode===`navigate`?void 0:this._strategy.fetchOptions);for(let t of this.iterateCallbacks(`fetchDidSucceed`))e=await t({event:n,request:a,response:e});return e}catch(e){throw i&&await this.runCallbacks(`fetchDidFail`,{error:e,event:n,originalRequest:i.clone(),request:a.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),n=t.clone();return this.waitUntil(this.cachePut(e,n)),t}async cacheMatch(e){let t=S(e),n,{cacheName:r,matchOptions:i}=this._strategy,a=await this.getCacheKey(t,`read`),o=Object.assign(Object.assign({},i),{cacheName:r});n=await caches.match(a,o);for(let e of this.iterateCallbacks(`cachedResponseWillBeUsed`))n=await e({cacheName:r,matchOptions:i,cachedResponse:n,request:a,event:this.event})||void 0;return n}async cachePut(e,n){let r=S(e);await x(0);let i=await this.getCacheKey(r,`write`);if(!n)throw new t(`cache-put-with-no-response`,{url:m(i.url)});let a=await this._ensureResponseSafeToCache(n);if(!a)return!1;let{cacheName:o,matchOptions:s}=this._strategy,c=await self.caches.open(o),l=this.hasCallback(`cacheDidUpdate`),u=l?await g(c,i.clone(),[`__WB_REVISION__`],s):null;try{await c.put(i,l?a.clone():a)}catch(e){if(e instanceof Error)throw e.name===`QuotaExceededError`&&await b(),e}for(let e of this.iterateCallbacks(`cacheDidUpdate`))await e({cacheName:o,oldResponse:u,newResponse:a.clone(),request:i,event:this.event});return!0}async getCacheKey(e,t){let n=`${e.url} | ${t}`;if(!this._cacheKeys[n]){let r=e;for(let e of this.iterateCallbacks(`cacheKeyWillBeUsed`))r=S(await e({mode:t,request:r,event:this.event,params:this.params}));this._cacheKeys[n]=r}return this._cacheKeys[n]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let n of this.iterateCallbacks(e))await n(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if(typeof t[e]==`function`){let n=this._pluginStateMap.get(t);yield r=>{let i=Object.assign(Object.assign({},r),{state:n});return t[e](i)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){let e=this._extendLifetimePromises.splice(0),t=(await Promise.allSettled(e)).find(e=>e.status===`rejected`);if(t)throw t.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,n=!1;for(let e of this.iterateCallbacks(`cacheWillUpdate`))if(t=await e({request:this.request,response:t,event:this.event})||void 0,n=!0,!t)break;return n||t&&t.status!==200&&(t=void 0),t}},w=class{constructor(e={}){this.cacheName=a.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,n=typeof e.request==`string`?new Request(e.request):e.request,r=`params`in e?e.params:void 0,i=new C(this,{event:t,request:n,params:r}),a=this._getResponse(i,n,t);return[a,this._awaitComplete(a,i,n,t)]}async _getResponse(e,n,r){await e.runCallbacks(`handlerWillStart`,{event:r,request:n});let i;try{if(i=await this._handle(n,e),!i||i.type===`error`)throw new t(`no-response`,{url:n.url})}catch(t){if(t instanceof Error){for(let a of e.iterateCallbacks(`handlerDidError`))if(i=await a({error:t,event:r,request:n}),i)break}if(!i)throw t}for(let t of e.iterateCallbacks(`handlerWillRespond`))i=await t({event:r,request:n,response:i});return i}async _awaitComplete(e,t,n,r){let i,a;try{i=await e}catch{}try{await t.runCallbacks(`handlerDidRespond`,{event:r,request:n,response:i}),await t.doneWaiting()}catch(e){e instanceof Error&&(a=e)}if(await t.runCallbacks(`handlerDidComplete`,{event:r,request:n,response:i,error:a}),t.destroy(),a)throw a}},T=class e extends w{constructor(t={}){t.cacheName=a.getPrecacheName(t.cacheName),super(t),this._fallbackToNetwork=t.fallbackToNetwork!==!1,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){return await t.cacheMatch(e)||(t.event&&t.event.type===`install`?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,n){let r,i=n.params||{};if(this._fallbackToNetwork){let t=i.integrity,a=e.integrity,o=!a||a===t;r=await n.fetch(new Request(e,{integrity:e.mode===`no-cors`?void 0:a||t})),t&&o&&e.mode!==`no-cors`&&(this._useDefaultCacheabilityPluginIfNeeded(),await n.cachePut(e,r.clone()))}else throw new t(`missing-precache-entry`,{cacheName:this.cacheName,url:e.url});return r}async _handleInstall(e,n){this._useDefaultCacheabilityPluginIfNeeded();let r=await n.fetch(e);if(!await n.cachePut(e,r.clone()))throw new t(`bad-precaching-response`,{url:e.url,status:r.status});return r}_useDefaultCacheabilityPluginIfNeeded(){let t=null,n=0;for(let[r,i]of this.plugins.entries())i!==e.copyRedirectedCacheableResponsesPlugin&&(i===e.defaultPrecacheCacheabilityPlugin&&(t=r),i.cacheWillUpdate&&n++);n===0?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):n>1&&t!==null&&this.plugins.splice(t,1)}};T.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:e}){return!e||e.status>=400?null:e}},T.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:e}){return e.redirected?await p(e):e}};var E=class{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:n=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new T({cacheName:a.getPrecacheName(e),plugins:[...t,new u({precacheController:this})],fallbackToNetwork:n}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||=(self.addEventListener(`install`,this.install),self.addEventListener(`activate`,this.activate),!0)}addToCacheList(e){let n=[];for(let r of e){typeof r==`string`?n.push(r):r&&r.revision===void 0&&n.push(r.url);let{cacheKey:e,url:i}=c(r),a=typeof r!=`string`&&r.revision?`reload`:`default`;if(this._urlsToCacheKeys.has(i)&&this._urlsToCacheKeys.get(i)!==e)throw new t(`add-to-cache-list-conflicting-entries`,{firstEntry:this._urlsToCacheKeys.get(i),secondEntry:e});if(typeof r!=`string`&&r.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==r.integrity)throw new t(`add-to-cache-list-conflicting-integrities`,{url:i});this._cacheKeysToIntegrities.set(e,r.integrity)}if(this._urlsToCacheKeys.set(i,e),this._urlsToCacheModes.set(i,a),n.length>0){let e=`Workbox is precaching URLs without revision info: ${n.join(`, `)}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(e)}}}install(e){return o(e,async()=>{let t=new l;this.strategy.plugins.push(t);for(let[t,n]of this._urlsToCacheKeys){let r=this._cacheKeysToIntegrities.get(n),i=this._urlsToCacheModes.get(t),a=new Request(t,{integrity:r,cache:i,credentials:`same-origin`});await Promise.all(this.strategy.handleAll({params:{cacheKey:n},request:a,event:e}))}let{updatedURLs:n,notUpdatedURLs:r}=t;return{updatedURLs:n,notUpdatedURLs:r}})}activate(e){return o(e,async()=>{let e=await self.caches.open(this.strategy.cacheName),t=await e.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(let i of t)n.has(i.url)||(await e.delete(i),r.push(i.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,n=this.getCacheKeyForURL(t);if(n)return(await self.caches.open(this.strategy.cacheName)).match(n)}createHandlerBoundToURL(e){let n=this.getCacheKeyForURL(e);if(!n)throw new t(`non-precached-url`,{url:e});return t=>(t.request=new Request(e),t.params=Object.assign({cacheKey:n},t.params),this.strategy.handle(t))}},D,O=()=>(D||=new E,D);try{self[`workbox:routing:7.3.0`]&&_()}catch{}var k=e=>e&&typeof e==`object`?e:{handle:e},A=class{constructor(e,t,n=`GET`){this.handler=k(t),this.match=e,this.method=n}setCatchHandler(e){this.catchHandler=k(e)}},j=class extends A{constructor(e,t,n){super(({url:t})=>{let n=e.exec(t.href);if(n&&!(t.origin!==location.origin&&n.index!==0))return n.slice(1)},t,n)}},M=class{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener(`fetch`,(e=>{let{request:t}=e,n=this.handleRequest({request:t,event:e});n&&e.respondWith(n)}))}addCacheListener(){self.addEventListener(`message`,(e=>{if(e.data&&e.data.type===`CACHE_URLS`){let{payload:t}=e.data,n=Promise.all(t.urlsToCache.map(t=>{typeof t==`string`&&(t=[t]);let n=new Request(...t);return this.handleRequest({request:n,event:e})}));e.waitUntil(n),e.ports&&e.ports[0]&&n.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){let n=new URL(e.url,location.href);if(!n.protocol.startsWith(`http`))return;let r=n.origin===location.origin,{params:i,route:a}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:n}),o=a&&a.handler,s=e.method;if(!o&&this._defaultHandlerMap.has(s)&&(o=this._defaultHandlerMap.get(s)),!o)return;let c;try{c=o.handle({url:n,request:e,event:t,params:i})}catch(e){c=Promise.reject(e)}let l=a&&a.catchHandler;return c instanceof Promise&&(this._catchHandler||l)&&(c=c.catch(async r=>{if(l)try{return await l.handle({url:n,request:e,event:t,params:i})}catch(e){e instanceof Error&&(r=e)}if(this._catchHandler)return this._catchHandler.handle({url:n,request:e,event:t});throw r})),c}findMatchingRoute({url:e,sameOrigin:t,request:n,event:r}){let i=this._routes.get(n.method)||[];for(let a of i){let i,o=a.match({url:e,sameOrigin:t,request:n,event:r});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o==`boolean`)&&(i=void 0),{route:a,params:i}}return{}}setDefaultHandler(e,t=`GET`){this._defaultHandlerMap.set(t,k(e))}setCatchHandler(e){this._catchHandler=k(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new t(`unregister-route-but-not-found-with-method`,{method:e.method});let n=this._routes.get(e.method).indexOf(e);if(n>-1)this._routes.get(e.method).splice(n,1);else throw new t(`unregister-route-route-not-registered`)}},N,P=()=>(N||(N=new M,N.addFetchListener(),N.addCacheListener()),N);function F(e,n,r){let i;if(typeof e==`string`){let t=new URL(e,location.href);i=new A(({url:e})=>e.href===t.href,n,r)}else if(e instanceof RegExp)i=new j(e,n,r);else if(typeof e==`function`)i=new A(e,n,r);else if(e instanceof A)i=e;else throw new t(`unsupported-route-type`,{moduleName:`workbox-routing`,funcName:`registerRoute`,paramName:`capture`});return P().registerRoute(i),i}function I(e,t=[]){for(let n of[...e.searchParams.keys()])t.some(e=>e.test(n))&&e.searchParams.delete(n);return e}function*L(e,{ignoreURLParametersMatching:t=[/^utm_/,/^fbclid$/],directoryIndex:n=`index.html`,cleanURLs:r=!0,urlManipulation:i}={}){let a=new URL(e,location.href);a.hash=``,yield a.href;let o=I(a,t);if(yield o.href,n&&o.pathname.endsWith(`/`)){let e=new URL(o.href);e.pathname+=n,yield e.href}if(r){let e=new URL(o.href);e.pathname+=`.html`,yield e.href}if(i){let e=i({url:a});for(let t of e)yield t.href}}var R=class extends A{constructor(e,t){super(({request:n})=>{let r=e.getURLsToCacheKeys();for(let i of L(n.url,t)){let t=r.get(i);if(t)return{cacheKey:t,integrity:e.getIntegrityForCacheKey(t)}}},e.strategy)}};function z(e){F(new R(O(),e))}function B(e){O().precache(e)}function V(e,t){B(e),z(t)}V([{"revision":"1872c500de691dce40960bb85481de07","url":"registerSW.js"},{"revision":"b4776ca319ebb6d86f9ea3fe28420e73","url":"index.html"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-512.svg"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-192.svg"},{"revision":null,"url":"assets/utils-bntUtdc7.js"},{"revision":null,"url":"assets/use-monaco-theme-Bb9W0CI2.js"},{"revision":null,"url":"assets/terminal-tab-BrP-ENHg.css"},{"revision":null,"url":"assets/terminal-tab-BeFf07MH.js"},{"revision":null,"url":"assets/tab-store-B1wzyDLQ.js"},{"revision":null,"url":"assets/settings-tab-BLoiK6Nc.js"},{"revision":null,"url":"assets/settings-store-DWYkr_a3.js"},{"revision":null,"url":"assets/markdown-renderer-DdDDhQDx.js"},{"revision":null,"url":"assets/jsx-runtime-BFALxl05.js"},{"revision":null,"url":"assets/input-AESbQWjx.js"},{"revision":null,"url":"assets/index-D27GI6gs.js"},{"revision":null,"url":"assets/index-C_yeSRZ0.css"},{"revision":null,"url":"assets/git-graph-DoLRBTMk.js"},{"revision":null,"url":"assets/external-link-CrtbmtJ6.js"},{"revision":null,"url":"assets/diff-viewer-B9oX4DDx.js"},{"revision":null,"url":"assets/copy-BNk4Z75P.js"},{"revision":null,"url":"assets/code-editor-Bj9jdnLm.js"},{"revision":null,"url":"assets/chat-tab-Bj1hZQ4x.js"},{"revision":null,"url":"assets/api-client-BsHoRDAn.js"},{"revision":"79c8870653c8f419f2e3323085e1f4be","url":"manifest.webmanifest"}]),self.addEventListener(`push`,e=>{e.waitUntil(self.clients.matchAll({type:`window`,includeUncontrolled:!0}).then(t=>{if(t.some(e=>e.visibilityState===`visible`))return;let n=e.data?.json()??{title:`PPM`,body:`Chat completed`};return self.registration.showNotification(n.title,{body:n.body,icon:`/icon-192.png`,badge:`/icon-192.png`,tag:`ppm-chat-done`,silent:!1,data:{url:self.location.origin}})}))}),self.addEventListener(`notificationclick`,e=>{e.notification.close(),e.waitUntil(self.clients.matchAll({type:`window`,includeUncontrolled:!0}).then(t=>{for(let e of t)if(e.url.includes(self.location.origin)&&`focus`in e)return e.focus();return self.clients.openWindow(e.notification.data?.url||`/`)}))});
1
+ try{self[`workbox:core:7.3.0`]&&_()}catch{}var e=(e,...t)=>{let n=e;return t.length>0&&(n+=` :: ${JSON.stringify(t)}`),n},t=class extends Error{constructor(t,n){let r=e(t,n);super(r),this.name=t,this.details=n}},n={googleAnalytics:`googleAnalytics`,precache:`precache-v2`,prefix:`workbox`,runtime:`runtime`,suffix:typeof registration<`u`?registration.scope:``},r=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join(`-`),i=e=>{for(let t of Object.keys(n))e(t)},a={updateDetails:e=>{i(t=>{typeof e[t]==`string`&&(n[t]=e[t])})},getGoogleAnalyticsName:e=>e||r(n.googleAnalytics),getPrecacheName:e=>e||r(n.precache),getPrefix:()=>n.prefix,getRuntimeName:e=>e||r(n.runtime),getSuffix:()=>n.suffix};function o(e,t){let n=t();return e.waitUntil(n),n}try{self[`workbox:precaching:7.3.0`]&&_()}catch{}var s=`__WB_REVISION__`;function c(e){if(!e)throw new t(`add-to-cache-list-unexpected-type`,{entry:e});if(typeof e==`string`){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:n,url:r}=e;if(!r)throw new t(`add-to-cache-list-unexpected-type`,{entry:e});if(!n){let e=new URL(r,location.href);return{cacheKey:e.href,url:e.href}}let i=new URL(r,location.href),a=new URL(r,location.href);return i.searchParams.set(s,n),{cacheKey:i.href,url:a.href}}var l=class{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:n})=>{if(e.type===`install`&&t&&t.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;n?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return n}}},u=class{constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:e,params:t})=>{let n=t?.cacheKey||this._precacheController.getCacheKeyForURL(e.url);return n?new Request(n,{headers:e.headers}):e},this._precacheController=e}},d;function f(){if(d===void 0){let e=new Response(``);if(`body`in e)try{new Response(e.body),d=!0}catch{d=!1}d=!1}return d}async function p(e,n){let r=null;if(e.url&&(r=new URL(e.url).origin),r!==self.location.origin)throw new t(`cross-origin-copy-response`,{origin:r});let i=e.clone(),a={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},o=n?n(a):a,s=f()?i.body:await i.blob();return new Response(s,o)}var m=e=>new URL(String(e),location.href).href.replace(RegExp(`^${location.origin}`),``);function h(e,t){let n=new URL(e);for(let e of t)n.searchParams.delete(e);return n.href}async function g(e,t,n,r){let i=h(t.url,n);if(t.url===i)return e.match(t,r);let a=Object.assign(Object.assign({},r),{ignoreSearch:!0}),o=await e.keys(t,a);for(let t of o)if(i===h(t.url,n))return e.match(t,r)}var v=class{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}},y=new Set;async function b(){for(let e of y)await e()}function x(e){return new Promise(t=>setTimeout(t,e))}try{self[`workbox:strategies:7.3.0`]&&_()}catch{}function S(e){return typeof e==`string`?new Request(e):e}var C=class{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new v,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(let e of this._plugins)this._pluginStateMap.set(e,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:n}=this,r=S(e);if(r.mode===`navigate`&&n instanceof FetchEvent&&n.preloadResponse){let e=await n.preloadResponse;if(e)return e}let i=this.hasCallback(`fetchDidFail`)?r.clone():null;try{for(let e of this.iterateCallbacks(`requestWillFetch`))r=await e({request:r.clone(),event:n})}catch(e){if(e instanceof Error)throw new t(`plugin-error-request-will-fetch`,{thrownErrorMessage:e.message})}let a=r.clone();try{let e;e=await fetch(r,r.mode===`navigate`?void 0:this._strategy.fetchOptions);for(let t of this.iterateCallbacks(`fetchDidSucceed`))e=await t({event:n,request:a,response:e});return e}catch(e){throw i&&await this.runCallbacks(`fetchDidFail`,{error:e,event:n,originalRequest:i.clone(),request:a.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),n=t.clone();return this.waitUntil(this.cachePut(e,n)),t}async cacheMatch(e){let t=S(e),n,{cacheName:r,matchOptions:i}=this._strategy,a=await this.getCacheKey(t,`read`),o=Object.assign(Object.assign({},i),{cacheName:r});n=await caches.match(a,o);for(let e of this.iterateCallbacks(`cachedResponseWillBeUsed`))n=await e({cacheName:r,matchOptions:i,cachedResponse:n,request:a,event:this.event})||void 0;return n}async cachePut(e,n){let r=S(e);await x(0);let i=await this.getCacheKey(r,`write`);if(!n)throw new t(`cache-put-with-no-response`,{url:m(i.url)});let a=await this._ensureResponseSafeToCache(n);if(!a)return!1;let{cacheName:o,matchOptions:s}=this._strategy,c=await self.caches.open(o),l=this.hasCallback(`cacheDidUpdate`),u=l?await g(c,i.clone(),[`__WB_REVISION__`],s):null;try{await c.put(i,l?a.clone():a)}catch(e){if(e instanceof Error)throw e.name===`QuotaExceededError`&&await b(),e}for(let e of this.iterateCallbacks(`cacheDidUpdate`))await e({cacheName:o,oldResponse:u,newResponse:a.clone(),request:i,event:this.event});return!0}async getCacheKey(e,t){let n=`${e.url} | ${t}`;if(!this._cacheKeys[n]){let r=e;for(let e of this.iterateCallbacks(`cacheKeyWillBeUsed`))r=S(await e({mode:t,request:r,event:this.event,params:this.params}));this._cacheKeys[n]=r}return this._cacheKeys[n]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let n of this.iterateCallbacks(e))await n(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if(typeof t[e]==`function`){let n=this._pluginStateMap.get(t);yield r=>{let i=Object.assign(Object.assign({},r),{state:n});return t[e](i)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){let e=this._extendLifetimePromises.splice(0),t=(await Promise.allSettled(e)).find(e=>e.status===`rejected`);if(t)throw t.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,n=!1;for(let e of this.iterateCallbacks(`cacheWillUpdate`))if(t=await e({request:this.request,response:t,event:this.event})||void 0,n=!0,!t)break;return n||t&&t.status!==200&&(t=void 0),t}},w=class{constructor(e={}){this.cacheName=a.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,n=typeof e.request==`string`?new Request(e.request):e.request,r=`params`in e?e.params:void 0,i=new C(this,{event:t,request:n,params:r}),a=this._getResponse(i,n,t);return[a,this._awaitComplete(a,i,n,t)]}async _getResponse(e,n,r){await e.runCallbacks(`handlerWillStart`,{event:r,request:n});let i;try{if(i=await this._handle(n,e),!i||i.type===`error`)throw new t(`no-response`,{url:n.url})}catch(t){if(t instanceof Error){for(let a of e.iterateCallbacks(`handlerDidError`))if(i=await a({error:t,event:r,request:n}),i)break}if(!i)throw t}for(let t of e.iterateCallbacks(`handlerWillRespond`))i=await t({event:r,request:n,response:i});return i}async _awaitComplete(e,t,n,r){let i,a;try{i=await e}catch{}try{await t.runCallbacks(`handlerDidRespond`,{event:r,request:n,response:i}),await t.doneWaiting()}catch(e){e instanceof Error&&(a=e)}if(await t.runCallbacks(`handlerDidComplete`,{event:r,request:n,response:i,error:a}),t.destroy(),a)throw a}},T=class e extends w{constructor(t={}){t.cacheName=a.getPrecacheName(t.cacheName),super(t),this._fallbackToNetwork=t.fallbackToNetwork!==!1,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){return await t.cacheMatch(e)||(t.event&&t.event.type===`install`?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,n){let r,i=n.params||{};if(this._fallbackToNetwork){let t=i.integrity,a=e.integrity,o=!a||a===t;r=await n.fetch(new Request(e,{integrity:e.mode===`no-cors`?void 0:a||t})),t&&o&&e.mode!==`no-cors`&&(this._useDefaultCacheabilityPluginIfNeeded(),await n.cachePut(e,r.clone()))}else throw new t(`missing-precache-entry`,{cacheName:this.cacheName,url:e.url});return r}async _handleInstall(e,n){this._useDefaultCacheabilityPluginIfNeeded();let r=await n.fetch(e);if(!await n.cachePut(e,r.clone()))throw new t(`bad-precaching-response`,{url:e.url,status:r.status});return r}_useDefaultCacheabilityPluginIfNeeded(){let t=null,n=0;for(let[r,i]of this.plugins.entries())i!==e.copyRedirectedCacheableResponsesPlugin&&(i===e.defaultPrecacheCacheabilityPlugin&&(t=r),i.cacheWillUpdate&&n++);n===0?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):n>1&&t!==null&&this.plugins.splice(t,1)}};T.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:e}){return!e||e.status>=400?null:e}},T.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:e}){return e.redirected?await p(e):e}};var E=class{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:n=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new T({cacheName:a.getPrecacheName(e),plugins:[...t,new u({precacheController:this})],fallbackToNetwork:n}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||=(self.addEventListener(`install`,this.install),self.addEventListener(`activate`,this.activate),!0)}addToCacheList(e){let n=[];for(let r of e){typeof r==`string`?n.push(r):r&&r.revision===void 0&&n.push(r.url);let{cacheKey:e,url:i}=c(r),a=typeof r!=`string`&&r.revision?`reload`:`default`;if(this._urlsToCacheKeys.has(i)&&this._urlsToCacheKeys.get(i)!==e)throw new t(`add-to-cache-list-conflicting-entries`,{firstEntry:this._urlsToCacheKeys.get(i),secondEntry:e});if(typeof r!=`string`&&r.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==r.integrity)throw new t(`add-to-cache-list-conflicting-integrities`,{url:i});this._cacheKeysToIntegrities.set(e,r.integrity)}if(this._urlsToCacheKeys.set(i,e),this._urlsToCacheModes.set(i,a),n.length>0){let e=`Workbox is precaching URLs without revision info: ${n.join(`, `)}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(e)}}}install(e){return o(e,async()=>{let t=new l;this.strategy.plugins.push(t);for(let[t,n]of this._urlsToCacheKeys){let r=this._cacheKeysToIntegrities.get(n),i=this._urlsToCacheModes.get(t),a=new Request(t,{integrity:r,cache:i,credentials:`same-origin`});await Promise.all(this.strategy.handleAll({params:{cacheKey:n},request:a,event:e}))}let{updatedURLs:n,notUpdatedURLs:r}=t;return{updatedURLs:n,notUpdatedURLs:r}})}activate(e){return o(e,async()=>{let e=await self.caches.open(this.strategy.cacheName),t=await e.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(let i of t)n.has(i.url)||(await e.delete(i),r.push(i.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,n=this.getCacheKeyForURL(t);if(n)return(await self.caches.open(this.strategy.cacheName)).match(n)}createHandlerBoundToURL(e){let n=this.getCacheKeyForURL(e);if(!n)throw new t(`non-precached-url`,{url:e});return t=>(t.request=new Request(e),t.params=Object.assign({cacheKey:n},t.params),this.strategy.handle(t))}},D,O=()=>(D||=new E,D);try{self[`workbox:routing:7.3.0`]&&_()}catch{}var k=e=>e&&typeof e==`object`?e:{handle:e},A=class{constructor(e,t,n=`GET`){this.handler=k(t),this.match=e,this.method=n}setCatchHandler(e){this.catchHandler=k(e)}},j=class extends A{constructor(e,t,n){super(({url:t})=>{let n=e.exec(t.href);if(n&&!(t.origin!==location.origin&&n.index!==0))return n.slice(1)},t,n)}},M=class{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener(`fetch`,(e=>{let{request:t}=e,n=this.handleRequest({request:t,event:e});n&&e.respondWith(n)}))}addCacheListener(){self.addEventListener(`message`,(e=>{if(e.data&&e.data.type===`CACHE_URLS`){let{payload:t}=e.data,n=Promise.all(t.urlsToCache.map(t=>{typeof t==`string`&&(t=[t]);let n=new Request(...t);return this.handleRequest({request:n,event:e})}));e.waitUntil(n),e.ports&&e.ports[0]&&n.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){let n=new URL(e.url,location.href);if(!n.protocol.startsWith(`http`))return;let r=n.origin===location.origin,{params:i,route:a}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:n}),o=a&&a.handler,s=e.method;if(!o&&this._defaultHandlerMap.has(s)&&(o=this._defaultHandlerMap.get(s)),!o)return;let c;try{c=o.handle({url:n,request:e,event:t,params:i})}catch(e){c=Promise.reject(e)}let l=a&&a.catchHandler;return c instanceof Promise&&(this._catchHandler||l)&&(c=c.catch(async r=>{if(l)try{return await l.handle({url:n,request:e,event:t,params:i})}catch(e){e instanceof Error&&(r=e)}if(this._catchHandler)return this._catchHandler.handle({url:n,request:e,event:t});throw r})),c}findMatchingRoute({url:e,sameOrigin:t,request:n,event:r}){let i=this._routes.get(n.method)||[];for(let a of i){let i,o=a.match({url:e,sameOrigin:t,request:n,event:r});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o==`boolean`)&&(i=void 0),{route:a,params:i}}return{}}setDefaultHandler(e,t=`GET`){this._defaultHandlerMap.set(t,k(e))}setCatchHandler(e){this._catchHandler=k(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new t(`unregister-route-but-not-found-with-method`,{method:e.method});let n=this._routes.get(e.method).indexOf(e);if(n>-1)this._routes.get(e.method).splice(n,1);else throw new t(`unregister-route-route-not-registered`)}},N,P=()=>(N||(N=new M,N.addFetchListener(),N.addCacheListener()),N);function F(e,n,r){let i;if(typeof e==`string`){let t=new URL(e,location.href);i=new A(({url:e})=>e.href===t.href,n,r)}else if(e instanceof RegExp)i=new j(e,n,r);else if(typeof e==`function`)i=new A(e,n,r);else if(e instanceof A)i=e;else throw new t(`unsupported-route-type`,{moduleName:`workbox-routing`,funcName:`registerRoute`,paramName:`capture`});return P().registerRoute(i),i}function I(e,t=[]){for(let n of[...e.searchParams.keys()])t.some(e=>e.test(n))&&e.searchParams.delete(n);return e}function*L(e,{ignoreURLParametersMatching:t=[/^utm_/,/^fbclid$/],directoryIndex:n=`index.html`,cleanURLs:r=!0,urlManipulation:i}={}){let a=new URL(e,location.href);a.hash=``,yield a.href;let o=I(a,t);if(yield o.href,n&&o.pathname.endsWith(`/`)){let e=new URL(o.href);e.pathname+=n,yield e.href}if(r){let e=new URL(o.href);e.pathname+=`.html`,yield e.href}if(i){let e=i({url:a});for(let t of e)yield t.href}}var R=class extends A{constructor(e,t){super(({request:n})=>{let r=e.getURLsToCacheKeys();for(let i of L(n.url,t)){let t=r.get(i);if(t)return{cacheKey:t,integrity:e.getIntegrityForCacheKey(t)}}},e.strategy)}};function z(e){F(new R(O(),e))}function B(e){O().precache(e)}function V(e,t){B(e),z(t)}V([{"revision":"1872c500de691dce40960bb85481de07","url":"registerSW.js"},{"revision":"1612fbe0df1759c9a202723adbeeb469","url":"index.html"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-512.svg"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-192.svg"},{"revision":null,"url":"assets/utils-bntUtdc7.js"},{"revision":null,"url":"assets/use-monaco-theme-CsNwoeyj.js"},{"revision":null,"url":"assets/terminal-tab-BrP-ENHg.css"},{"revision":null,"url":"assets/terminal-tab-BTumEYyO.js"},{"revision":null,"url":"assets/tab-store-Dq1kMOkJ.js"},{"revision":null,"url":"assets/settings-tab-BpETyigv.js"},{"revision":null,"url":"assets/settings-store-CGtTcr8r.js"},{"revision":null,"url":"assets/rotate-ccw-BesidNnx.js"},{"revision":null,"url":"assets/react-WvgCEYPV.js"},{"revision":null,"url":"assets/markdown-renderer-PHBaNQ3l.js"},{"revision":null,"url":"assets/jsx-runtime-B4BJKQ1u.js"},{"revision":null,"url":"assets/input-D-F4ITU0.js"},{"revision":null,"url":"assets/index-jmj5f_bQ.css"},{"revision":null,"url":"assets/index-CWwJBtaO.js"},{"revision":null,"url":"assets/git-graph-D6oftHHC.js"},{"revision":null,"url":"assets/diff-viewer-BWeMVAvK.js"},{"revision":null,"url":"assets/code-editor-hnDDc8JZ.js"},{"revision":null,"url":"assets/chat-tab-C6iTYbRI.js"},{"revision":null,"url":"assets/api-client-ANLU-Irq.js"},{"revision":"79c8870653c8f419f2e3323085e1f4be","url":"manifest.webmanifest"}]),self.addEventListener(`push`,e=>{e.waitUntil(self.clients.matchAll({type:`window`,includeUncontrolled:!0}).then(t=>{if(t.some(e=>e.visibilityState===`visible`))return;let n=e.data?.json()??{title:`PPM`,body:`Chat completed`};return self.registration.showNotification(n.title,{body:n.body,icon:`/icon-192.png`,badge:`/icon-192.png`,tag:`ppm-chat-done`,silent:!1,data:{url:self.location.origin}})}))}),self.addEventListener(`notificationclick`,e=>{e.notification.close(),e.waitUntil(self.clients.matchAll({type:`window`,includeUncontrolled:!0}).then(t=>{for(let e of t)if(e.url.includes(self.location.origin)&&`focus`in e)return e.focus();return self.clients.openWindow(e.notification.data?.url||`/`)}))});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -59,6 +59,7 @@
59
59
  "simple-git": "^3.33.0",
60
60
  "sonner": "^2.0.7",
61
61
  "tailwind-merge": "^3.5.0",
62
+ "use-stick-to-bottom": "^1.1.3",
62
63
  "vite-plugin-monaco-editor": "^1.1.0",
63
64
  "web-push": "^3.6.7",
64
65
  "zustand": "^5.0.11"
@@ -45,6 +45,14 @@ export async function stopServer() {
45
45
  // Kill tunnel process (independent from server)
46
46
  if (tunnelPid) killPid(tunnelPid, "tunnel");
47
47
 
48
+ // Windows fallback: kill orphan cloudflared processes spawned by PPM
49
+ if (process.platform === "win32") {
50
+ try {
51
+ const cfPath = resolve(homedir(), ".ppm", "bin", "cloudflared.exe");
52
+ Bun.spawnSync(["taskkill", "/F", "/IM", "cloudflared.exe"], { stdout: "ignore", stderr: "ignore" });
53
+ } catch {}
54
+ }
55
+
48
56
  cleanup();
49
57
  console.log("PPM stopped.");
50
58
  }
@@ -65,6 +65,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
65
65
  private pendingApprovals = new Map<string, PendingApproval>();
66
66
  /** Active query objects for abort support */
67
67
  private activeQueries = new Map<string, { close: () => void }>();
68
+ /** Fork source: ppmSessionId → sourceSessionId (used on first message to fork) */
69
+ private forkSources = new Map<string, string>();
68
70
  /** Latest known usage/rate-limit info (shared across all sessions) */
69
71
  private latestUsage: UsageInfo = {};
70
72
 
@@ -164,6 +166,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
164
166
  }
165
167
  }
166
168
 
169
+ /** Register a fork source — when this session sends its first message, it will fork from sourceId */
170
+ setForkSource(sessionId: string, sourceSessionId: string): void {
171
+ this.forkSources.set(sessionId, sourceSessionId);
172
+ }
173
+
167
174
  /**
168
175
  * Resolve a pending approval from FE (tool approval or AskUserQuestion answer).
169
176
  * Called by WS handler when client sends approval_response.
@@ -179,6 +186,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
179
186
  async *sendMessage(
180
187
  sessionId: string,
181
188
  message: string,
189
+ opts?: { forkSession?: boolean },
182
190
  ): AsyncIterable<ChatEvent> {
183
191
  if (!this.activeSessions.has(sessionId)) {
184
192
  await this.resumeSession(sessionId);
@@ -193,6 +201,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
193
201
  const isFirstMessage = count === 0;
194
202
  this.messageCount.set(sessionId, count + 1);
195
203
 
204
+ // Check if this session should fork from another
205
+ const forkSourceId = this.forkSources.get(sessionId);
206
+ const shouldFork = !!forkSourceId && isFirstMessage;
207
+ if (forkSourceId) this.forkSources.delete(sessionId);
208
+
196
209
  /**
197
210
  * Approval events to yield from the generator.
198
211
  * canUseTool pushes events here; the main loop yields them.
@@ -254,14 +267,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
254
267
  try {
255
268
  const providerConfig = this.getProviderConfig();
256
269
  // Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
257
- const sdkId = getSdkSessionId(sessionId);
258
- console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} cwd=${meta.projectPath ?? "(none)"}`);
270
+ // For fork: use the source session's SDK id
271
+ const sdkId = shouldFork ? getSdkSessionId(forkSourceId!) : getSdkSessionId(sessionId);
272
+ console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${meta.projectPath ?? "(none)"}`);
259
273
 
260
274
  const q = query({
261
275
  prompt: message,
262
276
  options: {
263
- sessionId: isFirstMessage ? sessionId : undefined,
264
- resume: isFirstMessage ? undefined : sdkId,
277
+ sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
278
+ resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
279
+ ...(shouldFork && { forkSession: true }),
265
280
  cwd: meta.projectPath,
266
281
  // Use full Claude Code system prompt (coding guidelines, security, response style)
267
282
  systemPrompt: { type: "preset", preset: "claude_code" },
@@ -328,9 +343,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
328
343
  continue;
329
344
  }
330
345
 
331
- // Handle `user` messages directly — they contain tool_result blocks (e.g. after Agent finishes).
332
- // Extract tool_results from user messages that are top-level (no parentId).
333
- if ((msg as any).type === "user" && !parentId) {
346
+ // Handle `user` messages — they contain tool_result blocks.
347
+ // Top-level: e.g. after Agent finishes. Child: subagent internal tool results.
348
+ if ((msg as any).type === "user") {
334
349
  const userContent = (msg as any).message?.content;
335
350
  if (Array.isArray(userContent)) {
336
351
  for (const block of userContent) {
@@ -341,8 +356,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
341
356
  output: typeof output === "string" ? output : JSON.stringify(output),
342
357
  isError: !!block.is_error,
343
358
  toolUseId: block.tool_use_id as string | undefined,
359
+ ...(parentId && { parentToolUseId: parentId }),
344
360
  };
345
- if (pendingToolCount > 0) pendingToolCount--;
361
+ if (!parentId && pendingToolCount > 0) pendingToolCount--;
346
362
  }
347
363
  }
348
364
  }
@@ -385,11 +401,18 @@ export class ClaudeAgentSdkProvider implements AIProvider {
385
401
  // Handle stream_event (raw API events) for text deltas
386
402
  if ((msg as any).type === "stream_event") {
387
403
  const event = partial.event;
388
- if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
389
- const text = event.delta.text ?? "";
390
- if (text) {
391
- lastPartialText += text;
392
- yield { type: "text", content: text, ...(parentId && { parentToolUseId: parentId }) };
404
+ if (event?.type === "content_block_delta") {
405
+ if (event.delta?.type === "text_delta") {
406
+ const text = event.delta.text ?? "";
407
+ if (text) {
408
+ lastPartialText += text;
409
+ yield { type: "text", content: text, ...(parentId && { parentToolUseId: parentId }) };
410
+ }
411
+ } else if (event.delta?.type === "thinking_delta") {
412
+ const thinking = event.delta.thinking ?? "";
413
+ if (thinking) {
414
+ yield { type: "thinking", content: thinking, ...(parentId && { parentToolUseId: parentId }) } as any;
415
+ }
393
416
  }
394
417
  }
395
418
  continue;
@@ -377,6 +377,9 @@ export async function startServer(options: {
377
377
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
378
378
  });
379
379
 
380
+ // Start background usage polling
381
+ import("../services/claude-usage.service.ts").then(({ startUsagePolling }) => startUsagePolling()).catch(() => {});
382
+
380
383
  console.log(`\n PPM v${VERSION} ready\n`);
381
384
  console.log(` ➜ Local: http://localhost:${server.port}/`);
382
385
 
@@ -415,6 +418,18 @@ export async function startServer(options: {
415
418
  console.log(` Token: ${configService.get("auth").token}`);
416
419
  }
417
420
  console.log();
421
+
422
+ // Graceful shutdown — stop server + tunnel on exit (especially important on Windows)
423
+ const shutdown = () => {
424
+ try { server.stop(true); } catch {}
425
+ try {
426
+ // Dynamic import to avoid circular — tunnel may not be loaded
427
+ import("../services/tunnel.service.ts").then(({ tunnelService }) => tunnelService.stopTunnel()).catch(() => {});
428
+ } catch {}
429
+ };
430
+ process.on("SIGINT", () => { shutdown(); process.exit(0); });
431
+ process.on("SIGTERM", () => { shutdown(); process.exit(0); });
432
+ process.on("exit", shutdown);
418
433
  }
419
434
 
420
435
  // Internal entry point for daemon child process
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { chatService } from "../../services/chat.service.ts";
6
6
  import { providerRegistry } from "../../providers/registry.ts";
7
7
  import { listSlashItems } from "../../services/slash-items.service.ts";
8
- import { waitForFreshUsage } from "../../services/claude-usage.service.ts";
8
+ import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
9
9
  import { getSessionLog } from "../../services/session-log.service.ts";
10
10
  import { ok, err } from "../../types/api.ts";
11
11
 
@@ -24,10 +24,14 @@ chatRoutes.get("/slash-items", (c) => {
24
24
  }
25
25
  });
26
26
 
27
- /** GET /chat/usage — await fresh data from ccburn (async, non-blocking to event loop) */
27
+ /** GET /chat/usage — return cached usage. ?refresh=1 forces fresh fetch first. */
28
28
  chatRoutes.get("/usage", async (c) => {
29
- const usage = await waitForFreshUsage();
29
+ if (c.req.query("refresh")) {
30
+ try { await refreshUsageNow(); } catch { /* use stale cache */ }
31
+ }
32
+ const usage = getCachedUsage();
30
33
  return c.json(ok({
34
+ lastFetchedAt: usage.lastFetchedAt,
31
35
  fiveHour: usage.session?.utilization,
32
36
  sevenDay: usage.weekly?.utilization,
33
37
  fiveHourResetsAt: usage.session?.resetsAt,
@@ -101,6 +105,30 @@ chatRoutes.delete("/sessions/:id", async (c) => {
101
105
  }
102
106
  });
103
107
 
108
+ /** POST /chat/sessions/:id/fork — fork session into a new one (for rewind/branch) */
109
+ chatRoutes.post("/sessions/:id/fork", async (c) => {
110
+ try {
111
+ const sourceId = c.req.param("id");
112
+ const projectName = c.get("projectName");
113
+ const projectPath = c.get("projectPath");
114
+ const providerId = c.req.query("providerId") ?? "claude";
115
+ // Create a new PPM session that will fork from sourceId on first message
116
+ const session = await chatService.createSession(providerId, {
117
+ projectName,
118
+ projectPath,
119
+ title: "Forked Chat",
120
+ });
121
+ // Store fork source so WS handler knows to use forkSession on first message
122
+ const provider = providerRegistry.get(providerId);
123
+ if (provider && "setForkSource" in provider) {
124
+ (provider as any).setForkSource(session.id, sourceId);
125
+ }
126
+ return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
127
+ } catch (e) {
128
+ return c.json(err((e as Error).message), 500);
129
+ }
130
+ });
131
+
104
132
  /** GET /chat/sessions/:id/logs — get session-level debug logs */
105
133
  chatRoutes.get("/sessions/:id/logs", (c) => {
106
134
  try {
@@ -73,12 +73,36 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
73
73
  entry.needsCatchUp = false;
74
74
  entry.catchUpText = "";
75
75
 
76
+ // Heartbeat interval — declared outside try so finally can clear it
77
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
78
+
76
79
  try {
77
80
  const userPreview = content.slice(0, 200);
78
81
  logSessionEvent(sessionId, "USER", userPreview);
79
82
  console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
83
+
84
+ // Send "connecting" status with thinking config so FE can set appropriate warning threshold
85
+ const { configService } = await import("../../services/config.service.ts");
86
+ const ai = configService.get("ai");
87
+ const pCfg = ai.providers[ai.default_provider ?? "claude"] ?? {};
88
+ const effort = (pCfg as Record<string, unknown>).effort as string | undefined;
89
+ const thinkingBudget = (pCfg as Record<string, unknown>).thinking_budget_tokens as number | undefined;
90
+ safeSend(sessionId, { type: "streaming_status", status: "connecting", effort, thinkingBudget });
91
+
80
92
  let eventCount = 0;
93
+ let firstEventReceived = false;
94
+ const startTime = Date.now();
81
95
 
96
+ // Heartbeat: while waiting for first response, send elapsed time every 5s
97
+ // so FE can show "Connecting... (15s)" and warn if it takes too long
98
+ heartbeat = setInterval(() => {
99
+ if (firstEventReceived || abortController.signal.aborted) {
100
+ clearInterval(heartbeat);
101
+ return;
102
+ }
103
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
104
+ safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
105
+ }, 5_000);
82
106
 
83
107
  for await (const event of chatService.sendMessage(providerId, sessionId, content)) {
84
108
  if (abortController.signal.aborted) break;
@@ -86,6 +110,13 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
86
110
  const ev = event as any;
87
111
  const evType = ev.type ?? "unknown";
88
112
 
113
+ // First event received — stop heartbeat, switch to streaming status
114
+ if (!firstEventReceived) {
115
+ firstEventReceived = true;
116
+ if (heartbeat) clearInterval(heartbeat);
117
+ safeSend(sessionId, { type: "streaming_status", status: "streaming" });
118
+ }
119
+
89
120
  // Log every event
90
121
  if (evType === "text") {
91
122
  logSessionEvent(sessionId, "TEXT", ev.content?.slice(0, 500) ?? "");
@@ -137,6 +168,9 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
137
168
  safeSend(sessionId, { type: "error", message: errMsg });
138
169
  }
139
170
  } finally {
171
+ if (heartbeat) clearInterval(heartbeat);
172
+ // Always send done — guarantees FE resets isStreaming even if provider didn't yield done
173
+ safeSend(sessionId, { type: "done", sessionId });
140
174
  entry.abort = undefined;
141
175
  entry.isStreaming = false;
142
176
  entry.pendingApprovalEvent = undefined;
@@ -237,6 +271,12 @@ export const chatWebSocket = {
237
271
  return;
238
272
  }
239
273
 
274
+ // Ensure entry.ws is current — may be stale if open/close race during reconnect
275
+ const entry0 = activeSessions.get(sessionId);
276
+ if (entry0 && entry0.ws !== ws) {
277
+ entry0.ws = ws;
278
+ }
279
+
240
280
  const entry = activeSessions.get(sessionId);
241
281
  const providerId = entry?.providerId ?? providerRegistry.getDefault().id;
242
282
 
@@ -11,7 +11,8 @@ export interface LimitBucket {
11
11
  }
12
12
 
13
13
  export interface ClaudeUsage {
14
- timestamp?: string;
14
+ /** ISO timestamp of last successful fetch */
15
+ lastFetchedAt?: string;
15
16
  session?: LimitBucket;
16
17
  weekly?: LimitBucket;
17
18
  weeklyOpus?: LimitBucket;
@@ -21,16 +22,21 @@ export interface ClaudeUsage {
21
22
  const API_URL = "https://api.anthropic.com/api/oauth/usage";
22
23
  const API_BETA = "oauth-2025-04-20";
23
24
  const USER_AGENT = "claude-code/1.0";
24
- const CACHE_TTL = 30_000; // 30s
25
25
  const FETCH_TIMEOUT = 10_000; // 10s
26
+ const POLL_INTERVAL = 60_000; // auto-fetch every 60s
27
+ const RETRY_DELAY = 5_000; // 5s between retries
28
+ const MAX_RETRIES = 3;
26
29
 
27
- /** Cached data + timestamp */
28
- let cache: { data: ClaudeUsage; timestamp: number } | null = null;
30
+ /** Cached usage data */
31
+ let cache: ClaudeUsage = {};
29
32
 
30
33
  /** Cached OAuth token (read once from Keychain/file) */
31
34
  let tokenCache: { token: string; timestamp: number } | null = null;
32
35
  const TOKEN_TTL = 300_000; // re-read token every 5min
33
36
 
37
+ /** Auto-poll timer */
38
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
39
+
34
40
  /**
35
41
  * Read OAuth access token from macOS Keychain, fallback to credentials file.
36
42
  */
@@ -66,9 +72,7 @@ function getAccessToken(): string {
66
72
  return token;
67
73
  }
68
74
 
69
- /**
70
- * Fetch usage from Anthropic OAuth API — native async, zero process spawn.
71
- */
75
+ /** Fetch usage from Anthropic OAuth API */
72
76
  async function fetchUsageFromApi(): Promise<ClaudeUsage> {
73
77
  const token = getAccessToken();
74
78
  const res = await fetch(API_URL, {
@@ -86,8 +90,7 @@ async function fetchUsageFromApi(): Promise<ClaudeUsage> {
86
90
  }
87
91
 
88
92
  const raw = (await res.json()) as Record<string, any>;
89
- const now = new Date().toISOString();
90
- const data: ClaudeUsage = { timestamp: now };
93
+ const data: ClaudeUsage = { lastFetchedAt: new Date().toISOString() };
91
94
 
92
95
  if (raw.five_hour) data.session = parseApiBucket(raw.five_hour, 5);
93
96
  if (raw.seven_day) data.weekly = parseApiBucket(raw.seven_day, 168);
@@ -113,20 +116,45 @@ function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBuc
113
116
  };
114
117
  }
115
118
 
116
- /**
117
- * Get cached usage or fetch fresh data.
118
- * Fully async, never blocks event loop just a native fetch().
119
- */
120
- export async function waitForFreshUsage(): Promise<ClaudeUsage> {
121
- if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
122
- return cache.data;
119
+ /** Fetch with retry logic */
120
+ async function fetchWithRetry(): Promise<void> {
121
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
122
+ try {
123
+ const data = await fetchUsageFromApi();
124
+ cache = data;
125
+ return;
126
+ } catch (e) {
127
+ const msg = (e as Error).message ?? "";
128
+ // Don't retry on 429 — just use stale cache
129
+ if (msg.includes("429")) return;
130
+ if (attempt < MAX_RETRIES) {
131
+ await new Promise((r) => setTimeout(r, RETRY_DELAY));
132
+ }
133
+ }
123
134
  }
135
+ }
124
136
 
125
- try {
126
- const data = await fetchUsageFromApi();
127
- cache = { data, timestamp: Date.now() };
128
- return data;
129
- } catch {
130
- return cache?.data ?? {};
131
- }
137
+ /** Start background auto-polling (called once on server start) */
138
+ export function startUsagePolling(): void {
139
+ if (pollTimer) return;
140
+ // Initial fetch
141
+ fetchWithRetry();
142
+ // Poll every POLL_INTERVAL
143
+ pollTimer = setInterval(() => fetchWithRetry(), POLL_INTERVAL);
144
+ }
145
+
146
+ /** Stop background polling */
147
+ export function stopUsagePolling(): void {
148
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
149
+ }
150
+
151
+ /** Get cached usage (fast, synchronous read — FE just reads this) */
152
+ export function getCachedUsage(): ClaudeUsage {
153
+ return cache;
154
+ }
155
+
156
+ /** Force immediate refresh (e.g. after a chat completes) */
157
+ export async function refreshUsageNow(): Promise<ClaudeUsage> {
158
+ await fetchWithRetry();
159
+ return cache;
132
160
  }
@@ -29,10 +29,13 @@ class TunnelService {
29
29
  if (this.cleanupHandler) {
30
30
  process.off("SIGINT", this.cleanupHandler);
31
31
  process.off("SIGTERM", this.cleanupHandler);
32
+ process.off("exit", this.cleanupHandler);
32
33
  }
33
34
  this.cleanupHandler = () => this.stopTunnel();
34
35
  process.on("SIGINT", this.cleanupHandler);
35
36
  process.on("SIGTERM", this.cleanupHandler);
37
+ // Windows: SIGINT/SIGTERM may not fire on Ctrl+C — use 'exit' as fallback
38
+ process.on("exit", this.cleanupHandler);
36
39
 
37
40
  // Read stderr to find tunnel URL, then keep draining to avoid SIGPIPE
38
41
  const reader = proc.stderr.getReader();
@@ -81,6 +84,7 @@ class TunnelService {
81
84
  if (this.cleanupHandler) {
82
85
  process.off("SIGINT", this.cleanupHandler);
83
86
  process.off("SIGTERM", this.cleanupHandler);
87
+ process.off("exit", this.cleanupHandler);
84
88
  this.cleanupHandler = null;
85
89
  }
86
90
  if (this.childProcess) {
package/src/types/api.ts CHANGED
@@ -29,6 +29,7 @@ export type ChatWsClientMessage =
29
29
 
30
30
  export type ChatWsServerMessage =
31
31
  | { type: "text"; content: string; parentToolUseId?: string }
32
+ | { type: "thinking"; content: string; parentToolUseId?: string }
32
33
  | { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string }
33
34
  | { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
34
35
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
package/src/types/chat.ts CHANGED
@@ -76,6 +76,7 @@ export type ResultSubtype =
76
76
 
77
77
  export type ChatEvent =
78
78
  | { type: "text"; content: string; parentToolUseId?: string }
79
+ | { type: "thinking"; content: string; parentToolUseId?: string }
79
80
  | { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string; children?: ChatEvent[] }
80
81
  | { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
81
82
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
package/src/web/app.tsx CHANGED
@@ -19,6 +19,7 @@ import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
19
19
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
20
20
  import { useHealthCheck } from "@/hooks/use-health-check";
21
21
  import { CommandPalette } from "@/components/layout/command-palette";
22
+ import { BugReportPopup } from "@/components/shared/bug-report-popup";
22
23
  import { cn } from "@/lib/utils";
23
24
 
24
25
  type AuthState = "checking" | "authenticated" | "unauthenticated";
@@ -210,9 +211,13 @@ export function App() {
210
211
  {/* Command palette (Shift+Shift) */}
211
212
  <CommandPalette open={paletteOpen} onClose={closePalette} initialQuery={paletteInitialQuery} />
212
213
 
214
+ {/* Global bug report popup */}
215
+ <BugReportPopup />
216
+
213
217
  {/* Toast notifications */}
214
218
  <Toaster
215
219
  position="bottom-left"
220
+ closeButton
216
221
  toastOptions={{
217
222
  className: "bg-surface border-border text-foreground",
218
223
  }}
@@ -15,7 +15,7 @@ interface ChatHistoryBarProps {
15
15
  usageInfo: UsageInfo;
16
16
  usageLoading?: boolean;
17
17
  refreshUsage?: () => void;
18
- lastUpdatedAt?: number | null;
18
+ lastFetchedAt?: string | null;
19
19
  sessionId?: string | null;
20
20
  onSelectSession?: (session: SessionInfo) => void;
21
21
  onBugReport?: () => void;
@@ -31,6 +31,15 @@ function formatDate(iso: string): string {
31
31
  }
32
32
  }
33
33
 
34
+ function relativeTime(iso: string): string {
35
+ const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
36
+ if (secs < 5) return "now";
37
+ if (secs < 60) return `${secs}s`;
38
+ const mins = Math.floor(secs / 60);
39
+ if (mins < 60) return `${mins}m`;
40
+ return `${Math.floor(mins / 60)}h`;
41
+ }
42
+
34
43
  function pctColor(pct: number): string {
35
44
  if (pct >= 90) return "text-red-500";
36
45
  if (pct >= 70) return "text-amber-500";
@@ -38,7 +47,7 @@ function pctColor(pct: number): string {
38
47
  }
39
48
 
40
49
  export function ChatHistoryBar({
41
- projectName, usageInfo, usageLoading, refreshUsage, lastUpdatedAt,
50
+ projectName, usageInfo, usageLoading, refreshUsage, lastFetchedAt,
42
51
  sessionId, onSelectSession, onBugReport, isConnected, onReconnect,
43
52
  }: ChatHistoryBarProps) {
44
53
  const [activePanel, setActivePanel] = useState<PanelType>(null);
@@ -133,6 +142,9 @@ export function ChatHistoryBar({
133
142
  <span>5h:{fiveHourPct != null ? `${fiveHourPct}%` : "--%"}</span>
134
143
  <span className="text-text-subtle">·</span>
135
144
  <span>Wk:{sevenDayPct != null ? `${sevenDayPct}%` : "--%"}</span>
145
+ {lastFetchedAt && (
146
+ <span className="text-text-subtle/50 font-normal text-[9px] ml-0.5">{relativeTime(lastFetchedAt)}</span>
147
+ )}
136
148
  </button>
137
149
 
138
150
  {/* Spacer */}
@@ -229,7 +241,7 @@ export function ChatHistoryBar({
229
241
  onClose={() => setActivePanel(null)}
230
242
  onReload={refreshUsage}
231
243
  loading={usageLoading}
232
- lastUpdatedAt={lastUpdatedAt}
244
+ lastFetchedAt={lastFetchedAt}
233
245
  />
234
246
  )}
235
247
  </div>