@hienlh/ppm 0.6.7 → 0.7.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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +86 -313
  3. package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
  4. package/dist/web/assets/{code-editor-BUg7alP6.js → code-editor-D6OuzcC-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CAgZOkZc.js → database-viewer-BxUpM_uA.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DVvY1aFb.js → diff-viewer-DAhrHpNM.js} +1 -1
  7. package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
  8. package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
  9. package/dist/web/assets/index-BU_07_oW.js +29 -0
  10. package/dist/web/assets/index-CBQhXXeV.css +2 -0
  11. package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-z99RjIxZ.js → markdown-renderer-CvGYO9sH.js} +2 -2
  13. package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
  14. package/dist/web/assets/{settings-tab-BnDkeQWk.js → settings-tab-Bwsxb41F.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-EwHWc37J.js → sqlite-viewer-DfgaCbWT.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-CTN18lb6.js → terminal-tab-D27e4ZTD.js} +2 -2
  17. package/dist/web/index.html +4 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/lib/network-utils.ts +12 -0
  21. package/src/server/routes/settings.ts +52 -0
  22. package/src/server/routes/tunnel.ts +1 -12
  23. package/src/server/ws/chat.ts +30 -3
  24. package/src/services/config.service.ts +1 -1
  25. package/src/services/notification.service.ts +42 -0
  26. package/src/services/telegram-notification.service.ts +106 -0
  27. package/src/types/config.ts +6 -0
  28. package/src/web/app.tsx +40 -1
  29. package/src/web/components/layout/draggable-tab.tsx +10 -2
  30. package/src/web/components/layout/mobile-nav.tsx +42 -3
  31. package/src/web/components/layout/project-bar.tsx +16 -8
  32. package/src/web/components/layout/tab-bar.tsx +55 -4
  33. package/src/web/components/settings/settings-tab.tsx +135 -94
  34. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  35. package/src/web/components/ui/accordion.tsx +64 -0
  36. package/src/web/hooks/use-chat.ts +29 -0
  37. package/src/web/hooks/use-notification-badge.ts +20 -0
  38. package/src/web/hooks/use-tab-overflow.ts +91 -0
  39. package/src/web/hooks/use-url-sync.ts +5 -2
  40. package/src/web/index.html +1 -0
  41. package/src/web/lib/favicon.ts +21 -0
  42. package/src/web/lib/notification-sounds.ts +61 -0
  43. package/src/web/stores/notification-store.ts +83 -0
  44. package/dist/web/assets/chat-tab-CWhxhPKH.js +0 -7
  45. package/dist/web/assets/git-graph-xD6TLRVv.js +0 -1
  46. package/dist/web/assets/index-CigdXBuQ.css +0 -2
  47. package/dist/web/assets/index-DBdw8tN_.js +0 -22
  48. package/dist/web/assets/keybindings-store-kHLASnRb.js +0 -1
  49. package/dist/web/assets/postgres-viewer-CaMySHpD.js +0 -1
  50. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
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":"ba24b0a24eb3a419f20bb0032733de86","url":"index.html"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-512.svg"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-192.svg"},{"revision":"eb9818b9094675c0c5d303168f273345","url":"monacoeditorwork/ts.worker.bundle.js"},{"revision":"9af0be92dcefdc1f1290441cb5ff5d9b","url":"monacoeditorwork/json.worker.bundle.js"},{"revision":"a261b429c39dbb75ae97972d7d005e6d","url":"monacoeditorwork/html.worker.bundle.js"},{"revision":"79953d804e1bbacecfd79b85fd679016","url":"monacoeditorwork/editor.worker.bundle.js"},{"revision":"fdcba0d09aac31df7a0bc652f6e739bd","url":"monacoeditorwork/css.worker.bundle.js"},{"revision":null,"url":"assets/utils-siJJ3uG0.js"},{"revision":null,"url":"assets/use-monaco-theme-Dexl3s3E.js"},{"revision":null,"url":"assets/terminal-tab-CTN18lb6.js"},{"revision":null,"url":"assets/terminal-tab-BrP-ENHg.css"},{"revision":null,"url":"assets/table-DCVKGOr2.js"},{"revision":null,"url":"assets/tab-store-DIyJSjtr.js"},{"revision":null,"url":"assets/sqlite-viewer-EwHWc37J.js"},{"revision":null,"url":"assets/settings-tab-BnDkeQWk.js"},{"revision":null,"url":"assets/settings-store-CfB0vCtQ.js"},{"revision":null,"url":"assets/react-DHSo28we.js"},{"revision":null,"url":"assets/react-CYzKIDNi.js"},{"revision":null,"url":"assets/postgres-viewer-CaMySHpD.js"},{"revision":null,"url":"assets/markdown-renderer-z99RjIxZ.js"},{"revision":null,"url":"assets/keybindings-store-kHLASnRb.js"},{"revision":null,"url":"assets/jsx-runtime-wQxeESYQ.js"},{"revision":null,"url":"assets/input-nI4xe1Y9.js"},{"revision":null,"url":"assets/index-DBdw8tN_.js"},{"revision":null,"url":"assets/index-CigdXBuQ.css"},{"revision":null,"url":"assets/git-graph-xD6TLRVv.js"},{"revision":null,"url":"assets/dist-Jb3Tnkpc.js"},{"revision":null,"url":"assets/diff-viewer-DVvY1aFb.js"},{"revision":null,"url":"assets/database-viewer-CAgZOkZc.js"},{"revision":null,"url":"assets/code-editor-BUg7alP6.js"},{"revision":null,"url":"assets/chat-tab-CWhxhPKH.js"},{"revision":null,"url":"assets/api-client-4Ni0i4Hl.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":"4276cff85869e97ffbe37bc4b5426eda","url":"index.html"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-512.svg"},{"revision":"a0fb34fc84eb148d51812cd62669f20d","url":"icon-192.svg"},{"revision":"eb9818b9094675c0c5d303168f273345","url":"monacoeditorwork/ts.worker.bundle.js"},{"revision":"9af0be92dcefdc1f1290441cb5ff5d9b","url":"monacoeditorwork/json.worker.bundle.js"},{"revision":"a261b429c39dbb75ae97972d7d005e6d","url":"monacoeditorwork/html.worker.bundle.js"},{"revision":"79953d804e1bbacecfd79b85fd679016","url":"monacoeditorwork/editor.worker.bundle.js"},{"revision":"fdcba0d09aac31df7a0bc652f6e739bd","url":"monacoeditorwork/css.worker.bundle.js"},{"revision":null,"url":"assets/utils-siJJ3uG0.js"},{"revision":null,"url":"assets/use-monaco-theme-Dexl3s3E.js"},{"revision":null,"url":"assets/terminal-tab-D27e4ZTD.js"},{"revision":null,"url":"assets/terminal-tab-BrP-ENHg.css"},{"revision":null,"url":"assets/table-DCVKGOr2.js"},{"revision":null,"url":"assets/tab-store-Bm1Hw8OR.js"},{"revision":null,"url":"assets/sqlite-viewer-DfgaCbWT.js"},{"revision":null,"url":"assets/settings-tab-Bwsxb41F.js"},{"revision":null,"url":"assets/settings-store-CfB0vCtQ.js"},{"revision":null,"url":"assets/react-DHSo28we.js"},{"revision":null,"url":"assets/react-CYzKIDNi.js"},{"revision":null,"url":"assets/postgres-viewer-BL99auSm.js"},{"revision":null,"url":"assets/markdown-renderer-CvGYO9sH.js"},{"revision":null,"url":"assets/keybindings-store-C0m8_V9X.js"},{"revision":null,"url":"assets/jsx-runtime-wQxeESYQ.js"},{"revision":null,"url":"assets/input-nI4xe1Y9.js"},{"revision":null,"url":"assets/index-CBQhXXeV.css"},{"revision":null,"url":"assets/index-BU_07_oW.js"},{"revision":null,"url":"assets/git-graph-BpTt5iOd.js"},{"revision":null,"url":"assets/dist-CNRrBoQi.js"},{"revision":null,"url":"assets/diff-viewer-DAhrHpNM.js"},{"revision":null,"url":"assets/database-viewer-BxUpM_uA.js"},{"revision":null,"url":"assets/code-editor-D6OuzcC-.js"},{"revision":null,"url":"assets/chat-tab-CbNbBMGw.js"},{"revision":null,"url":"assets/api-client-4Ni0i4Hl.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.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,12 @@
1
+ import { networkInterfaces } from "node:os";
2
+
3
+ /** Return first non-internal IPv4 address, or null if none found */
4
+ export function getLocalIp(): string | null {
5
+ const nets = networkInterfaces();
6
+ for (const name of Object.keys(nets)) {
7
+ for (const net of nets[name] ?? []) {
8
+ if (net.family === "IPv4" && !net.internal) return net.address;
9
+ }
10
+ }
11
+ return null;
12
+ }
@@ -6,6 +6,7 @@ import {
6
6
  validateDefaultProvider,
7
7
  VALID_PROVIDERS,
8
8
  type AIProviderConfig,
9
+ type TelegramConfig,
9
10
  type ThemeConfig,
10
11
  } from "../../types/config.ts";
11
12
  import { ok, err } from "../../types/api.ts";
@@ -135,3 +136,54 @@ settingsRoutes.put("/keybindings", async (c) => {
135
136
  return c.json(err((e as Error).message), 400);
136
137
  }
137
138
  });
139
+
140
+ // ── Telegram ──────────────────────────────────────────────────────────
141
+
142
+ /** GET /settings/telegram — return current telegram config (masks bot_token) */
143
+ settingsRoutes.get("/telegram", (c) => {
144
+ const tg = configService.get("telegram") as TelegramConfig | undefined;
145
+ if (!tg) return c.json(ok({ bot_token: "", chat_id: "" }));
146
+ return c.json(ok({
147
+ bot_token: tg.bot_token ? `${tg.bot_token.slice(0, 6)}...` : "",
148
+ chat_id: tg.chat_id,
149
+ }));
150
+ });
151
+
152
+ /** PUT /settings/telegram — save telegram bot_token + chat_id */
153
+ settingsRoutes.put("/telegram", async (c) => {
154
+ try {
155
+ const body = await c.req.json<{ bot_token?: string; chat_id?: string }>();
156
+ const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "", chat_id: "" };
157
+ const updated: TelegramConfig = {
158
+ bot_token: body.bot_token ?? current.bot_token,
159
+ chat_id: body.chat_id ?? current.chat_id,
160
+ };
161
+ configService.set("telegram", updated);
162
+ configService.save();
163
+ return c.json(ok({
164
+ bot_token: updated.bot_token ? `${updated.bot_token.slice(0, 6)}...` : "",
165
+ chat_id: updated.chat_id,
166
+ }));
167
+ } catch (e) {
168
+ return c.json(err((e as Error).message), 400);
169
+ }
170
+ });
171
+
172
+ /** POST /settings/telegram/test — send a test message */
173
+ settingsRoutes.post("/telegram/test", async (c) => {
174
+ try {
175
+ const body = await c.req.json<{ bot_token?: string; chat_id?: string }>();
176
+ const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "", chat_id: "" };
177
+ const token = body.bot_token || current.bot_token;
178
+ const chatId = body.chat_id || current.chat_id;
179
+ if (!token || !chatId) {
180
+ return c.json(err("bot_token and chat_id are required"), 400);
181
+ }
182
+ const { telegramService } = await import("../../services/telegram-notification.service.ts");
183
+ const result = await telegramService.sendTest(token, chatId);
184
+ if (!result.ok) return c.json(err(result.error ?? "Failed"), 500);
185
+ return c.json(ok({ sent: true }));
186
+ } catch (e) {
187
+ return c.json(err((e as Error).message), 500);
188
+ }
189
+ });
@@ -1,20 +1,9 @@
1
1
  import { Hono } from "hono";
2
- import { networkInterfaces } from "node:os";
3
2
  import { tunnelService } from "../../services/tunnel.service.ts";
4
3
  import { configService } from "../../services/config.service.ts";
4
+ import { getLocalIp } from "../../lib/network-utils.ts";
5
5
  import { ok, err } from "../../types/api.ts";
6
6
 
7
- /** Return first non-internal IPv4 address */
8
- function getLocalIp(): string | null {
9
- const nets = networkInterfaces();
10
- for (const name of Object.keys(nets)) {
11
- for (const net of nets[name] ?? []) {
12
- if (net.family === "IPv4" && !net.internal) return net.address;
13
- }
14
- }
15
- return null;
16
- }
17
-
18
7
  export const tunnelRoutes = new Hono();
19
8
 
20
9
  /** GET /api/tunnel — current tunnel status + local URL */
@@ -35,6 +35,14 @@ interface SessionEntry {
35
35
  /** Tracks active sessions — persists even when FE disconnects */
36
36
  const activeSessions = new Map<string, SessionEntry>();
37
37
 
38
+ /** Check if any frontend client is currently connected via WebSocket */
39
+ export function hasActiveClient(): boolean {
40
+ for (const entry of activeSessions.values()) {
41
+ if (entry.ws) return true;
42
+ }
43
+ return false;
44
+ }
45
+
38
46
  /** Send event to FE if connected, silently drop otherwise */
39
47
  function safeSend(sessionId: string, event: unknown): void {
40
48
  const entry = activeSessions.get(sessionId);
@@ -159,15 +167,34 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
159
167
  if (session) session.title = found.summary;
160
168
  }
161
169
  }).catch(() => {});
162
- // Fire-and-forget push notification
163
- import("../../services/push-notification.service.ts").then(({ pushService }) => {
170
+ // Fire-and-forget notification broadcast (push + telegram)
171
+ import("../../services/notification.service.ts").then(({ notificationService }) => {
164
172
  const project = entry.projectName || "Project";
165
173
  const session = chatService.getSession(sessionId);
166
174
  const sessionTitle = session?.title || `Session ${sessionId.slice(0, 8)}`;
167
- pushService.notifyAll("Chat completed", `${project} — ${sessionTitle}`).catch(() => {});
175
+ notificationService.broadcast("done", {
176
+ title: "Chat completed",
177
+ body: `${project} — ${sessionTitle}`,
178
+ project,
179
+ sessionId,
180
+ sessionTitle,
181
+ });
168
182
  }).catch(() => {});
169
183
  } else if (evType === "approval_request") {
170
184
  entry.pendingApprovalEvent = ev;
185
+ // Fire-and-forget notification for approval/question
186
+ import("../../services/notification.service.ts").then(({ notificationService }) => {
187
+ const project = entry.projectName || "Project";
188
+ const session = chatService.getSession(sessionId);
189
+ const sTitle = session?.title || `Session ${sessionId.slice(0, 8)}`;
190
+ const isQuestion = ev.tool === "AskUserQuestion";
191
+ const nType = isQuestion ? "question" : "approval_request";
192
+ const title = isQuestion ? "AI has a question" : "Waiting for approval";
193
+ const body = isQuestion
194
+ ? `${project} — ${sTitle}`
195
+ : `${project} — ${ev.tool} needs permission`;
196
+ notificationService.broadcast(nType as any, { title, body, project, sessionId, sessionTitle: sTitle, tool: ev.tool });
197
+ }).catch(() => {});
171
198
  } else {
172
199
  logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
173
200
  }
@@ -19,7 +19,7 @@ const PPM_DIR = resolve(homedir(), ".ppm");
19
19
 
20
20
  /** Top-level config keys stored in the config table (not projects) */
21
21
  const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
22
- "device_name", "port", "host", "theme", "auth", "ai", "push",
22
+ "device_name", "port", "host", "theme", "auth", "ai", "push", "telegram",
23
23
  ];
24
24
 
25
25
  class ConfigService {
@@ -0,0 +1,42 @@
1
+ import { hasActiveClient } from "../server/ws/chat.ts";
2
+
3
+ export type NotificationType = "done" | "approval_request" | "question";
4
+
5
+ export interface NotificationPayload {
6
+ title: string;
7
+ body: string;
8
+ project: string;
9
+ sessionId: string;
10
+ sessionTitle?: string;
11
+ tool?: string;
12
+ deviceName?: string;
13
+ }
14
+
15
+ class NotificationService {
16
+ /** Broadcast notification to all channels (push, telegram). Fire-and-forget. */
17
+ async broadcast(_type: NotificationType, payload: NotificationPayload): Promise<void> {
18
+ const tasks: Promise<void>[] = [];
19
+ const userOnline = hasActiveClient();
20
+
21
+ // Push notifications — always send (works as ambient alert)
22
+ tasks.push(
23
+ import("./push-notification.service.ts")
24
+ .then(({ pushService }) => pushService.notifyAll(payload.title, payload.body))
25
+ .catch(() => {}),
26
+ );
27
+
28
+ // Telegram — only when user has no active browser session
29
+ if (!userOnline) {
30
+ tasks.push(
31
+ import("./telegram-notification.service.ts")
32
+ .then(({ telegramService }) => telegramService.send(payload))
33
+ .catch(() => {}),
34
+ );
35
+ }
36
+
37
+ await Promise.allSettled(tasks);
38
+ }
39
+ }
40
+
41
+ /** Singleton notification dispatcher */
42
+ export const notificationService = new NotificationService();
@@ -0,0 +1,106 @@
1
+ import { configService } from "./config.service.ts";
2
+ import { tunnelService } from "./tunnel.service.ts";
3
+ import { getLocalIp } from "../lib/network-utils.ts";
4
+ import type { TelegramConfig } from "../types/config.ts";
5
+ import type { NotificationPayload } from "./notification.service.ts";
6
+
7
+ const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
8
+
9
+ /** Escape HTML special chars for Telegram HTML parse mode */
10
+ function escapeHtml(str: string): string {
11
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
+ }
13
+
14
+ class TelegramNotificationService {
15
+ /** Send notification to Telegram. No-op if not configured. */
16
+ async send(payload: NotificationPayload): Promise<void> {
17
+ const config = configService.get("telegram") as TelegramConfig | undefined;
18
+ if (!config?.bot_token || !config?.chat_id) return;
19
+ if (!BOT_TOKEN_RE.test(config.bot_token)) return;
20
+
21
+ const deviceName = (configService.get("device_name") as string) || "PPM";
22
+ const deepLink = this.buildDeepLink(payload);
23
+
24
+ let text = `<b>${escapeHtml(deviceName)} — ${escapeHtml(payload.title)}</b>\n`;
25
+ text += escapeHtml(payload.body);
26
+ if (deepLink) {
27
+ text += `\n\n<a href="${deepLink}">Open in PPM</a>`;
28
+ }
29
+
30
+ await this.callApi(config.bot_token, config.chat_id, text);
31
+ }
32
+
33
+ /** Send a test message. Returns { ok, error? } */
34
+ async sendTest(botToken: string, chatId: string): Promise<{ ok: boolean; error?: string }> {
35
+ if (!BOT_TOKEN_RE.test(botToken)) return { ok: false, error: "Invalid bot token format" };
36
+ const deviceName = (configService.get("device_name") as string) || "PPM";
37
+ const text = `<b>${escapeHtml(deviceName)} — Test</b>\nTelegram notifications are working!`;
38
+ const controller = new AbortController();
39
+ const timeout = setTimeout(() => controller.abort(), 10_000);
40
+ try {
41
+ const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML" }),
45
+ signal: controller.signal,
46
+ });
47
+ const json = (await res.json()) as { ok: boolean; description?: string };
48
+ if (!json.ok) return { ok: false, error: json.description || "Unknown error" };
49
+ return { ok: true };
50
+ } catch (e) {
51
+ return { ok: false, error: (e as Error).message };
52
+ } finally {
53
+ clearTimeout(timeout);
54
+ }
55
+ }
56
+
57
+ private buildDeepLink(payload: NotificationPayload): string | null {
58
+ // Prefer tunnel URL (globally accessible), fallback to local IP
59
+ let baseUrl = tunnelService.getTunnelUrl();
60
+ if (!baseUrl) {
61
+ const localIp = getLocalIp();
62
+ const port = configService.get("port") ?? 8080;
63
+ if (localIp) {
64
+ baseUrl = `http://${localIp}:${port}`;
65
+ }
66
+ }
67
+ if (!baseUrl) return null;
68
+
69
+ const projectPath = payload.project
70
+ ? `/project/${encodeURIComponent(payload.project)}`
71
+ : "";
72
+ const query = payload.sessionId ? `?openChat=${payload.sessionId}` : "";
73
+ return `${baseUrl}${projectPath}${query}`;
74
+ }
75
+
76
+ private async callApi(token: string, chatId: string, text: string): Promise<void> {
77
+ if (!BOT_TOKEN_RE.test(token)) return;
78
+ const controller = new AbortController();
79
+ const timeout = setTimeout(() => controller.abort(), 10_000);
80
+
81
+ try {
82
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({
86
+ chat_id: chatId,
87
+ text,
88
+ parse_mode: "HTML",
89
+ disable_web_page_preview: true,
90
+ }),
91
+ signal: controller.signal,
92
+ });
93
+ if (!res.ok) {
94
+ const errBody = await res.text();
95
+ console.error(`[telegram] sendMessage failed: ${res.status} ${errBody}`);
96
+ }
97
+ } catch (e) {
98
+ console.error(`[telegram] send error: ${(e as Error).message}`);
99
+ } finally {
100
+ clearTimeout(timeout);
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Singleton Telegram notification service */
106
+ export const telegramService = new TelegramNotificationService();
@@ -4,6 +4,11 @@ export interface PushConfig {
4
4
  vapid_subject: string;
5
5
  }
6
6
 
7
+ export interface TelegramConfig {
8
+ bot_token: string;
9
+ chat_id: string;
10
+ }
11
+
7
12
  export type ThemeConfig = "light" | "dark" | "system";
8
13
 
9
14
  export interface PpmConfig {
@@ -15,6 +20,7 @@ export interface PpmConfig {
15
20
  projects: ProjectConfig[];
16
21
  ai: AIConfig;
17
22
  push?: PushConfig;
23
+ telegram?: TelegramConfig;
18
24
  }
19
25
 
20
26
  export interface AuthConfig {
package/src/web/app.tsx CHANGED
@@ -18,6 +18,7 @@ import { getAuthToken } from "@/lib/api-client";
18
18
  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
+ import { useNotificationBadge } from "@/hooks/use-notification-badge";
21
22
  import { CommandPalette } from "@/components/layout/command-palette";
22
23
  import { BugReportPopup } from "@/components/shared/bug-report-popup";
23
24
  import { cn } from "@/lib/utils";
@@ -102,6 +103,19 @@ export function App() {
102
103
  // Health check — detects server crash/restart
103
104
  useHealthCheck();
104
105
 
106
+ // Notification badge — syncs document.title + favicon with unread count
107
+ useNotificationBadge();
108
+
109
+ // Warn before closing browser tab (prevents accidental Ctrl+W)
110
+ useEffect(() => {
111
+ if (authState !== "authenticated") return;
112
+ const handler = (e: BeforeUnloadEvent) => {
113
+ e.preventDefault();
114
+ };
115
+ window.addEventListener("beforeunload", handler);
116
+ return () => window.removeEventListener("beforeunload", handler);
117
+ }, [authState]);
118
+
105
119
  // Load keybindings after auth confirmed (must not call ApiClient before auth)
106
120
  useEffect(() => {
107
121
  if (authState !== "authenticated") return;
@@ -115,7 +129,7 @@ export function App() {
115
129
  if (authState !== "authenticated") return;
116
130
 
117
131
  fetchProjects().then(() => {
118
- const { projectName: urlProject, tabId: urlTab } = initialUrlRef.current;
132
+ const { projectName: urlProject, tabId: urlTab, openChat } = initialUrlRef.current;
119
133
  const { projects, customOrder } = useProjectStore.getState();
120
134
  if (projects.length === 0) return;
121
135
 
@@ -135,6 +149,31 @@ export function App() {
135
149
  });
136
150
  }
137
151
  }
152
+
153
+ // Deep link: ?openChat=sessionId — open/focus the chat tab
154
+ if (openChat) {
155
+ queueMicrotask(() => {
156
+ const { tabs, setActiveTab, openTab } = useTabStore.getState();
157
+ const existing = tabs.find(
158
+ (t) => t.type === "chat" && t.metadata?.sessionId === openChat,
159
+ );
160
+ if (existing) {
161
+ setActiveTab(existing.id);
162
+ } else {
163
+ openTab({
164
+ type: "chat",
165
+ title: "Chat",
166
+ projectId: target?.name ?? null,
167
+ closable: true,
168
+ metadata: { sessionId: openChat },
169
+ });
170
+ }
171
+ // Clean up query param
172
+ const url = new URL(window.location.href);
173
+ url.searchParams.delete("openChat");
174
+ window.history.replaceState(null, "", url.pathname);
175
+ });
176
+ }
138
177
  });
139
178
  }, [authState, fetchProjects]);
140
179
 
@@ -3,12 +3,15 @@ import { X } from "lucide-react";
3
3
  import type { Tab, TabType } from "@/stores/tab-store";
4
4
  import { cn } from "@/lib/utils";
5
5
  import { isDarkColor } from "@/lib/color-utils";
6
+ import { notificationColor } from "@/stores/notification-store";
6
7
 
7
8
  interface DraggableTabProps {
8
9
  tab: Tab;
9
10
  isActive: boolean;
10
11
  icon: React.ElementType;
11
12
  showDropBefore: boolean;
13
+ /** Notification type if unread (null = no unread). Controls badge color. */
14
+ notificationType?: string | null;
12
15
  onSelect: () => void;
13
16
  onClose: () => void;
14
17
  onDragStart: (e: React.DragEvent) => void;
@@ -20,7 +23,7 @@ interface DraggableTabProps {
20
23
  }
21
24
 
22
25
  export function DraggableTab({
23
- tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
26
+ tab, isActive, icon: Icon, showDropBefore, notificationType, onSelect, onClose,
24
27
  onDragStart, onDragOver, onDragEnd, tabRef, onRename,
25
28
  }: DraggableTabProps) {
26
29
  const [editing, setEditing] = useState(false);
@@ -74,7 +77,12 @@ export function DraggableTab({
74
77
  colorStyle && "border-transparent",
75
78
  )}
76
79
  >
77
- <Icon className="size-4" />
80
+ <span className="relative">
81
+ <Icon className="size-4" />
82
+ {notificationType && !isActive && (
83
+ <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
84
+ )}
85
+ </span>
78
86
  {editing ? (
79
87
  <input
80
88
  ref={inputRef}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import {
3
3
  Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
5
+ ChevronLeft, ChevronRight,
5
6
  } from "lucide-react";
6
7
  import { usePanelStore } from "@/stores/panel-store";
7
8
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
@@ -11,6 +12,8 @@ import { getProjectInitials } from "@/lib/project-avatar";
11
12
  import type { TabType } from "@/stores/tab-store";
12
13
  import { cn } from "@/lib/utils";
13
14
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
15
+ import { useNotificationStore, notificationColor } from "@/stores/notification-store";
16
+ import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
14
17
 
15
18
  const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
16
19
  { type: "terminal", label: "Terminal" },
@@ -35,7 +38,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
35
38
  const tabs = panel?.tabs ?? [];
36
39
  const activeTabId = panel?.activeTabId ?? null;
37
40
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
41
+ const mobileScrollRef = useRef<HTMLDivElement>(null);
38
42
  const prevTabCount = useRef(tabs.length);
43
+ const notifications = useNotificationStore((s) => s.notifications);
44
+ const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
45
+ useTabOverflow(mobileScrollRef);
46
+ const hiddenUnread = getHiddenUnreadDirection(mobileScrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
39
47
 
40
48
  const [menuTabId, setMenuTabId] = useState<string | null>(null);
41
49
  const [newTabSheetOpen, setNewTabSheetOpen] = useState(false);
@@ -109,15 +117,31 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
109
117
  <Menu className="size-5" />
110
118
  </button>
111
119
 
112
- <div className="flex-1 flex items-center h-12 overflow-x-auto">
120
+ <div className="flex-1 relative flex items-center h-12">
121
+ {/* Left scroll arrow */}
122
+ {canScrollLeft && (
123
+ <button onClick={doScrollLeft} className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent">
124
+ <span className="relative">
125
+ <ChevronLeft className="size-3.5 text-text-secondary" />
126
+ {hiddenUnread.left && <span className={cn("absolute -top-1 -right-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.left))} />}
127
+ </span>
128
+ </button>
129
+ )}
130
+ <div ref={mobileScrollRef} className="flex-1 flex items-center h-12 overflow-x-auto scrollbar-none">
113
131
  {tabs.map((tab) => {
114
132
  const Icon = TAB_ICONS[tab.type];
115
133
  const isActive = tab.id === activeTabId;
134
+ const sessionId = tab.type === "chat" ? (tab.metadata?.sessionId as string) : undefined;
135
+ const entry = sessionId ? notifications.get(sessionId) : undefined;
136
+ const notiType = entry && entry.count > 0 ? entry.type : null;
116
137
  return (
117
138
  <button
118
139
  key={tab.id}
119
140
  ref={(el) => { if (el) tabRefs.current.set(tab.id, el); else tabRefs.current.delete(tab.id); }}
120
- onClick={() => usePanelStore.getState().setActiveTab(tab.id)}
141
+ onClick={() => {
142
+ usePanelStore.getState().setActiveTab(tab.id);
143
+ if (sessionId) useNotificationStore.getState().clearForSession(sessionId);
144
+ }}
121
145
  onTouchStart={() => startLongPress(tab.id)}
122
146
  onTouchEnd={cancelLongPress}
123
147
  onTouchMove={cancelLongPress}
@@ -127,7 +151,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
127
151
  isActive ? "border-primary bg-surface text-primary" : "border-transparent text-text-secondary",
128
152
  )}
129
153
  >
130
- <Icon className="size-4" />
154
+ <span className="relative">
155
+ <Icon className="size-4" />
156
+ {notiType && !isActive && (
157
+ <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notiType))} />
158
+ )}
159
+ </span>
131
160
  <span className="max-w-[80px] truncate">{tab.title}</span>
132
161
  {tab.closable && (
133
162
  <span role="button" tabIndex={0} onClick={(e) => { e.stopPropagation(); usePanelStore.getState().closeTab(tab.id); }}
@@ -138,6 +167,16 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
138
167
  </button>
139
168
  );
140
169
  })}
170
+ </div>
171
+ {/* Right scroll arrow */}
172
+ {canScrollRight && (
173
+ <button onClick={doScrollRight} className="absolute right-0 z-10 flex items-center justify-center size-8 bg-gradient-to-l from-background via-background to-transparent">
174
+ <span className="relative">
175
+ <ChevronRight className="size-3.5 text-text-secondary" />
176
+ {hiddenUnread.right && <span className={cn("absolute -top-1 -left-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.right))} />}
177
+ </span>
178
+ </button>
179
+ )}
141
180
  </div>
142
181
 
143
182
  {/* Add tab — opens command palette */}