From eb20fcf0b4dcea8a447ebd63ac6566d335aca0ff Mon Sep 17 00:00:00 2001 From: Nils <104727469+nilskroe@users.noreply.github.com> Date: Sun, 25 Jan 2026 07:27:33 +0100 Subject: [PATCH 01/24] feat: add preview sidebar with dev server support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreviewSidebar component with webview for live preview - Add port detection system for terminal sessions - Add running dev server indicator in workspace sidebar (animated blue dot) - Add keyboard shortcut (cmd+R) to toggle preview sidebar - Add stop button reliability improvements (SIGTERM → SIGKILL escalation) - Add element selection for context from preview Co-Authored-By: Claude Opus 4.5 --- bun.lock | 255 +++++- drizzle/0005_add_subchat_stats.sql | 3 - drizzle/0005_marvelous_master_chief.sql | 5 +- electron.vite.config.ts | 3 + src/main/lib/terminal/manager.ts | 39 +- src/main/lib/terminal/port-manager.ts | 65 +- src/main/lib/terminal/port-scanner.ts | 290 ++++++ src/main/lib/terminal/types.ts | 7 +- src/main/lib/trpc/routers/terminal.ts | 81 +- src/main/windows/main.ts | 1 + src/renderer/components/ui/canvas-icons.tsx | 2 +- src/renderer/components/ui/icons.tsx | 2 +- .../components/ui/resizable-sidebar.tsx | 8 + src/renderer/features/agents/atoms/index.ts | 25 + .../hooks/use-preview-element-selection.ts | 81 ++ .../features/agents/lib/agents-actions.ts | 14 + .../agents/lib/agents-hotkeys-manager.ts | 22 +- src/renderer/features/agents/lib/drafts.ts | 59 +- .../features/agents/lib/queue-utils.ts | 32 +- .../features/agents/main/active-chat.tsx | 85 ++ .../features/agents/main/chat-input-area.tsx | 46 +- .../agents/ui/agent-preview-element-item.tsx | 75 ++ .../details-sidebar/sections/info-section.tsx | 2 +- .../features/layout/agents-layout.tsx | 6 + .../features/preview-sidebar/index.ts | 7 + .../preview-sidebar/preview-sidebar.tsx | 833 ++++++++++++++++++ .../preview-sidebar/terminal-output.tsx | 118 +++ .../features/preview-sidebar/types.ts | 14 + .../features/preview-sidebar/url-parser.ts | 69 ++ .../features/sidebar/agents-sidebar.tsx | 24 + .../features/sidebar/mcp-servers-popover.tsx | 406 +++++++++ .../sidebar/running-servers-popover.tsx | 550 ++++++++++++ src/renderer/features/terminal/types.ts | 23 +- src/renderer/index.html | 2 +- src/renderer/lib/atoms/index.ts | 8 + src/renderer/lib/hotkeys/shortcut-registry.ts | 6 + src/renderer/lib/hotkeys/types.ts | 1 + 37 files changed, 3223 insertions(+), 46 deletions(-) delete mode 100644 drizzle/0005_add_subchat_stats.sql create mode 100644 src/renderer/features/agents/hooks/use-preview-element-selection.ts create mode 100644 src/renderer/features/agents/ui/agent-preview-element-item.tsx create mode 100644 src/renderer/features/preview-sidebar/index.ts create mode 100644 src/renderer/features/preview-sidebar/preview-sidebar.tsx create mode 100644 src/renderer/features/preview-sidebar/terminal-output.tsx create mode 100644 src/renderer/features/preview-sidebar/types.ts create mode 100644 src/renderer/features/preview-sidebar/url-parser.ts create mode 100644 src/renderer/features/sidebar/mcp-servers-popover.tsx create mode 100644 src/renderer/features/sidebar/running-servers-popover.tsx diff --git a/bun.lock b/bun.lock index 3e0fc8d8..e988a362 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "21st-desktop", @@ -51,6 +52,7 @@ "gray-matter": "^4.0.3", "jotai": "^2.11.1", "lucide-react": "^0.468.0", + "mermaid": "^11.12.2", "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", @@ -62,6 +64,7 @@ "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", "react-syntax-highlighter": "^16.1.0", + "react-zoom-pan-pinch": "^3.7.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^1.24.4", @@ -112,6 +115,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.15", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-KN3jrHR5tIcAfLbplK5xHqNyUS3XnG8DMnImGeVEv64Z8NxfxIWtJTxtuBRWjyYzo36PEhK4r2SkX97A2iG+ng=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -160,6 +165,18 @@ "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -256,6 +273,10 @@ "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -310,6 +331,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -636,6 +659,68 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -644,6 +729,8 @@ "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="], @@ -842,6 +929,10 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], @@ -888,6 +979,8 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "config-file-ts": ["config-file-ts@0.2.8-rc1", "", { "dependencies": { "glob": "^10.3.12", "typescript": "^5.4.3" } }, "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg=="], "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], @@ -910,6 +1003,8 @@ "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -922,8 +1017,82 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -940,6 +1109,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], @@ -1154,6 +1325,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -1230,6 +1403,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1306,10 +1481,18 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -1320,6 +1503,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], @@ -1356,7 +1541,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -1400,6 +1585,8 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1490,6 +1677,8 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "motion": ["motion@11.18.2", "", { "dependencies": { "framer-motion": "^11.18.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg=="], @@ -1560,12 +1749,16 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1576,6 +1769,8 @@ "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -1598,8 +1793,14 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -1682,6 +1883,8 @@ "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], + "reactivity-store": ["reactivity-store@0.3.12", "", { "dependencies": { "@vue/reactivity": "~3.5.22", "@vue/shared": "~3.5.22", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ=="], "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], @@ -1746,12 +1949,18 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -1850,6 +2059,8 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], @@ -1884,6 +2095,8 @@ "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -1902,6 +2115,8 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1914,6 +2129,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1952,6 +2169,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -1964,6 +2183,18 @@ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2012,6 +2243,10 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -2106,10 +2341,20 @@ "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], "config-file-ts/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -2134,6 +2379,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2184,6 +2431,8 @@ "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "streamdown/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "streamdown/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -2274,6 +2523,10 @@ "config-file-ts/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], diff --git a/drizzle/0005_add_subchat_stats.sql b/drizzle/0005_add_subchat_stats.sql deleted file mode 100644 index ee7b112f..00000000 --- a/drizzle/0005_add_subchat_stats.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `sub_chats` ADD `additions` integer DEFAULT 0;--> statement-breakpoint -ALTER TABLE `sub_chats` ADD `deletions` integer DEFAULT 0;--> statement-breakpoint -ALTER TABLE `sub_chats` ADD `file_count` integer DEFAULT 0; diff --git a/drizzle/0005_marvelous_master_chief.sql b/drizzle/0005_marvelous_master_chief.sql index 500963e1..45531cbf 100644 --- a/drizzle/0005_marvelous_master_chief.sql +++ b/drizzle/0005_marvelous_master_chief.sql @@ -1,4 +1 @@ -CREATE INDEX `chats_worktree_path_idx` ON `chats` (`worktree_path`);--> statement-breakpoint -ALTER TABLE `sub_chats` DROP COLUMN `additions`;--> statement-breakpoint -ALTER TABLE `sub_chats` DROP COLUMN `deletions`;--> statement-breakpoint -ALTER TABLE `sub_chats` DROP COLUMN `file_count`; \ No newline at end of file +CREATE INDEX `chats_worktree_path_idx` ON `chats` (`worktree_path`); diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 832050e5..ab9b6530 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -49,6 +49,9 @@ export default defineConfig({ }, renderer: { plugins: [react()], + server: { + port: 5199, // Use non-default port to avoid conflict with worktree dev servers + }, resolve: { alias: { "@": resolve(__dirname, "src/renderer"), diff --git a/src/main/lib/terminal/manager.ts b/src/main/lib/terminal/manager.ts index 0f6c9706..f99ec797 100644 --- a/src/main/lib/terminal/manager.ts +++ b/src/main/lib/terminal/manager.ts @@ -65,6 +65,9 @@ export class TerminalManager extends EventEmitter { portManager.registerSession(session, workspaceId || "") + // Emit started event so subscribers know a session was created + this.emit(`started:${paneId}`, session.cwd) + return { isNew: true, serializedState: "", @@ -193,11 +196,41 @@ export class TerminalManager extends EventEmitter { return } - if (session.isAlive) { - session.pty.kill() - } else { + if (!session.isAlive) { this.sessions.delete(paneId) + return + } + + // Send SIGTERM first + try { + session.pty.kill("SIGTERM") + } catch (error) { + console.error(`[TerminalManager] Failed to send SIGTERM to ${paneId}:`, error) } + + // Wait for graceful shutdown, then escalate to SIGKILL + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!session.isAlive) { + clearInterval(checkInterval) + clearTimeout(forceKillTimeout) + resolve() + } + }, 100) + + const forceKillTimeout = setTimeout(() => { + clearInterval(checkInterval) + if (session.isAlive) { + try { + session.pty.kill("SIGKILL") + } catch (error) { + console.error(`[TerminalManager] Failed to send SIGKILL to ${paneId}:`, error) + } + } + // Give SIGKILL a moment to take effect + setTimeout(resolve, 100) + }, 2000) + }) } detach(params: { paneId: string; serializedState?: string }): void { diff --git a/src/main/lib/terminal/port-manager.ts b/src/main/lib/terminal/port-manager.ts index 8f28bc60..71e99301 100644 --- a/src/main/lib/terminal/port-manager.ts +++ b/src/main/lib/terminal/port-manager.ts @@ -1,6 +1,10 @@ import { EventEmitter } from "node:events" import type { DetectedPort } from "./types" -import { getListeningPortsForPids, getProcessTree } from "./port-scanner" +import { + getListeningPortsForPids, + getProcessTree, + getAllListeningPorts, +} from "./port-scanner" import type { TerminalSession } from "./types" // How often to poll for port changes (in ms) @@ -9,6 +13,9 @@ const SCAN_INTERVAL_MS = 2500 // Ports to ignore (common system ports that are usually not dev servers) const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]) +// Special pane ID for ports not associated with a terminal session +const SYSTEM_PANE_ID = "__system__" + interface RegisteredSession { session: TerminalSession workspaceId: string @@ -24,6 +31,10 @@ class PortManager extends EventEmitter { constructor() { super() this.startPeriodicScan() + // Run initial scan immediately + this.scanAllSessions().catch((error) => { + console.error("[PortManager] Initial scan error:", error) + }) } /** @@ -80,39 +91,65 @@ class PortManager extends EventEmitter { } /** - * Scan all registered sessions for ports + * Scan all listening ports on the system + * Associates ports with terminal sessions when possible, + * otherwise marks them as system ports */ private async scanAllSessions(): Promise { if (this.isScanning) return this.isScanning = true try { - const panePortMap = new Map< - string, - { workspaceId: string; pids: number[] } - >() + // Get all listening ports on the system + const allPorts = await getAllListeningPorts() + // Debug: log found ports + if (allPorts.length > 0) { + console.log(`[PortManager] Found ${allPorts.length} listening ports`) + } + // Build a map of PID -> paneId for terminal sessions + const pidToPaneMap = new Map() for (const [paneId, { session, workspaceId }] of this.sessions) { if (!session.isAlive) continue try { - const pid = session.pty.pid - const pids = await getProcessTree(pid) - if (pids.length > 0) { - panePortMap.set(paneId, { workspaceId, pids }) + const pids = await getProcessTree(session.pty.pid) + for (const pid of pids) { + pidToPaneMap.set(pid, { paneId, workspaceId }) } } catch { // Session may have exited } } - for (const [paneId, { workspaceId, pids }] of panePortMap) { - const portInfos = await getListeningPortsForPids(pids) - this.updatePortsForPane(paneId, workspaceId, portInfos) + // Group ports by pane (or system for unassociated ports) + const panePortMap = new Map< + string, + { workspaceId: string; ports: typeof allPorts } + >() + + for (const portInfo of allPorts) { + if (IGNORED_PORTS.has(portInfo.port)) continue + + const sessionInfo = pidToPaneMap.get(portInfo.pid) + const paneId = sessionInfo?.paneId ?? SYSTEM_PANE_ID + const workspaceId = sessionInfo?.workspaceId ?? "" + + if (!panePortMap.has(paneId)) { + panePortMap.set(paneId, { workspaceId, ports: [] }) + } + panePortMap.get(paneId)!.ports.push(portInfo) + } + + // Update ports for each pane + for (const [paneId, { workspaceId, ports }] of panePortMap) { + this.updatePortsForPane(paneId, workspaceId, ports) } + // Remove ports for panes that no longer have any + const activePaneIds = new Set(panePortMap.keys()) for (const [key, port] of this.ports) { - if (!this.sessions.has(port.paneId)) { + if (!activePaneIds.has(port.paneId)) { this.ports.delete(key) this.emit("port:remove", port) } diff --git a/src/main/lib/terminal/port-scanner.ts b/src/main/lib/terminal/port-scanner.ts index 7ac1cd3c..f484706a 100644 --- a/src/main/lib/terminal/port-scanner.ts +++ b/src/main/lib/terminal/port-scanner.ts @@ -244,6 +244,296 @@ async function getProcessNameWindowsAsync(pid: number): Promise { return name } +/** + * Get ALL listening TCP ports on the system (not filtered by PID) + * Used to detect servers started by processes outside the terminal manager + */ +export async function getAllListeningPorts(): Promise { + const platform = os.platform() + + if (platform === "darwin" || platform === "linux") { + return getAllListeningPortsLsof() + } else if (platform === "win32") { + return getAllListeningPortsWindows() + } + + return [] +} + +/** + * Get a friendly process name from full command line + * Extracts the most useful part for display (e.g., "vite" from "node .../vite/bin/vite.js") + */ +function getFriendlyProcessName( + pid: number, + basicName: string, + commandLine: string, +): string { + if (!commandLine || commandLine === basicName) { + return basicName + } + + // Common dev server patterns - check command line for these + const devServerPatterns: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /vite/i, name: "Vite" }, + { pattern: /next[\s/]/, name: "Next.js" }, + { pattern: /webpack[-\s]dev[-\s]server/i, name: "Webpack Dev Server" }, + { pattern: /react-scripts\s+start/i, name: "Create React App" }, + { pattern: /nuxt/i, name: "Nuxt" }, + { pattern: /astro/i, name: "Astro" }, + { pattern: /remix/i, name: "Remix" }, + { pattern: /svelte/i, name: "SvelteKit" }, + { pattern: /angular/i, name: "Angular" }, + { pattern: /electron/i, name: "Electron" }, + { pattern: /bun\s+(run\s+)?dev/i, name: "Bun Dev" }, + { pattern: /tsx\s+watch/i, name: "TSX Watch" }, + { pattern: /nodemon/i, name: "Nodemon" }, + { pattern: /ts-node/i, name: "TS-Node" }, + { pattern: /express/i, name: "Express" }, + { pattern: /fastify/i, name: "Fastify" }, + { pattern: /nest/i, name: "NestJS" }, + { pattern: /storybook/i, name: "Storybook" }, + { pattern: /wrangler/i, name: "Wrangler" }, + { pattern: /miniflare/i, name: "Miniflare" }, + { pattern: /workerd/i, name: "Workerd" }, + ] + + for (const { pattern, name } of devServerPatterns) { + if (pattern.test(commandLine)) { + return name + } + } + + // For node/bun processes, try to extract the script name + if (basicName === "node" || basicName === "bun") { + // Look for .js, .ts, .mjs files in the command + const scriptMatch = commandLine.match(/\s([^\s]+\.(?:js|ts|mjs|cjs))/i) + if (scriptMatch) { + // Get just the filename without path + const script = scriptMatch[1].split("/").pop() || scriptMatch[1] + return `${basicName} ${script}` + } + } + + // For other processes, return the basic name + return basicName +} + +/** + * Get command line for PIDs (macOS/Linux) + */ +async function getCommandLinesForPids( + pids: number[], +): Promise> { + const result = new Map() + if (pids.length === 0) return result + + try { + const pidArg = pids.join(",") + const { stdout: output } = await execFileAsync( + "sh", + ["-c", `ps -p ${pidArg} -o pid=,command= 2>/dev/null || true`], + { maxBuffer: 10 * 1024 * 1024, timeout: 3000 }, + ) + + for (const line of output.trim().split("\n")) { + if (!line.trim()) continue + // Format: PID COMMAND (command can have spaces) + const match = line.match(/^\s*(\d+)\s+(.+)$/) + if (match) { + const pid = Number.parseInt(match[1], 10) + const command = match[2].trim() + result.set(pid, command) + } + } + } catch { + // Ignore errors + } + + return result +} + +/** + * macOS/Linux: Get all listening TCP ports using lsof + */ +async function getAllListeningPortsLsof(): Promise { + try { + // -iTCP: only TCP connections + // -sTCP:LISTEN: only listening sockets + // -P: don't convert port numbers to names + // -n: don't resolve hostnames + const { stdout: output } = await execFileAsync( + "sh", + ["-c", "lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true"], + { maxBuffer: 10 * 1024 * 1024, timeout: 5000 }, + ) + + if (!output.trim()) return [] + + // First pass: collect all ports and PIDs + const rawPorts: Array<{ + port: number + pid: number + address: string + basicName: string + }> = [] + const pidsToLookup = new Set() + + const lines = output.trim().split("\n").slice(1) + for (const line of lines) { + if (!line.trim()) continue + + // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + const columns = line.split(/\s+/) + if (columns.length < 10) continue + + const basicName = columns[0] + const pid = Number.parseInt(columns[1], 10) + const name = columns[columns.length - 2] // NAME column (e.g., *:3000) + + // Parse address:port from NAME column + const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/) + if (match) { + const address = match[1] || match[2] || "*" + const port = Number.parseInt(match[3], 10) + + if (port < 1 || port > 65535) continue + + rawPorts.push({ + port, + pid, + address: address === "*" ? "0.0.0.0" : address, + basicName, + }) + pidsToLookup.add(pid) + } + } + + // Get command lines for all PIDs + const commandLines = await getCommandLinesForPids(Array.from(pidsToLookup)) + + // Build final result with friendly names + const ports: PortInfo[] = rawPorts.map((p) => ({ + port: p.port, + pid: p.pid, + address: p.address, + processName: getFriendlyProcessName( + p.pid, + p.basicName, + commandLines.get(p.pid) || "", + ), + })) + + return ports + } catch { + return [] + } +} + +/** + * Get command lines for PIDs (Windows) + */ +async function getCommandLinesForPidsWindows( + pids: number[], +): Promise> { + const result = new Map() + if (pids.length === 0) return result + + try { + // Use wmic to get command lines + const pidList = pids.join(",") + const { stdout: output } = await execFileAsync( + "powershell", + [ + "-Command", + `Get-CimInstance Win32_Process -Filter "ProcessId in (${pidList})" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation`, + ], + { timeout: 5000 }, + ) + + const lines = output.trim().split("\n").slice(1) // Skip header + for (const line of lines) { + // CSV format: "PID","CommandLine" + const match = line.match(/"(\d+)","([^"]*)"/) + if (match) { + const pid = Number.parseInt(match[1], 10) + const commandLine = match[2] + result.set(pid, commandLine) + } + } + } catch { + // Ignore errors + } + + return result +} + +/** + * Windows: Get all listening TCP ports using netstat + */ +async function getAllListeningPortsWindows(): Promise { + try { + const { stdout: output } = await execFileAsync("netstat", ["-ano"], { + maxBuffer: 10 * 1024 * 1024, + timeout: 5000, + }) + + const uniquePids = new Set() + const rawPorts: Array<{ + pid: number + address: string + port: number + }> = [] + + for (const line of output.split("\n")) { + if (!line.includes("LISTENING")) continue + + const columns = line.trim().split(/\s+/) + if (columns.length < 5) continue + + const pid = Number.parseInt(columns[columns.length - 1], 10) + const localAddr = columns[1] + + const match = localAddr.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/) + if (match) { + const address = match[1] || match[2] || "0.0.0.0" + const port = Number.parseInt(match[3], 10) + + if (port < 1 || port > 65535) continue + + uniquePids.add(pid) + rawPorts.push({ pid, address, port }) + } + } + + // Fetch process names and command lines + const [processNames, commandLines] = await Promise.all([ + Promise.all( + Array.from(uniquePids).map(async (pid) => { + const name = await getProcessNameWindowsAsync(pid) + return [pid, name] as const + }), + ).then((entries) => new Map(entries)), + getCommandLinesForPidsWindows(Array.from(uniquePids)), + ]) + + const ports: PortInfo[] = rawPorts.map((p) => { + const basicName = processNames.get(p.pid) || "unknown" + const commandLine = commandLines.get(p.pid) || "" + return { + port: p.port, + pid: p.pid, + address: p.address, + processName: getFriendlyProcessName(p.pid, basicName, commandLine), + } + }) + + return ports + } catch { + return [] + } +} + /** * Get process name for a PID (cross-platform, async with cache) */ diff --git a/src/main/lib/terminal/types.ts b/src/main/lib/terminal/types.ts index ec8f80d8..bad7201d 100644 --- a/src/main/lib/terminal/types.ts +++ b/src/main/lib/terminal/types.ts @@ -15,6 +15,11 @@ export interface TerminalSession { usedFallback: boolean } +export interface TerminalStartedEvent { + type: "started" + cwd: string +} + export interface TerminalDataEvent { type: "data" data: string @@ -26,7 +31,7 @@ export interface TerminalExitEvent { signal?: number } -export type TerminalEvent = TerminalDataEvent | TerminalExitEvent +export type TerminalEvent = TerminalStartedEvent | TerminalDataEvent | TerminalExitEvent export interface SessionResult { isNew: boolean diff --git a/src/main/lib/trpc/routers/terminal.ts b/src/main/lib/trpc/routers/terminal.ts index 50bf4e17..5118fc3b 100644 --- a/src/main/lib/trpc/routers/terminal.ts +++ b/src/main/lib/trpc/routers/terminal.ts @@ -4,7 +4,8 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { observable } from "@trpc/server/observable" import { terminalManager } from "../../terminal/manager" -import type { TerminalEvent } from "../../terminal/types" +import { portManager } from "../../terminal/port-manager" +import type { TerminalEvent, DetectedPort } from "../../terminal/types" import { TRPCError } from "@trpc/server" export const terminalRouter = router({ @@ -188,22 +189,98 @@ export const terminalRouter = router({ .input(z.string().min(1)) .subscription(({ input: paneId }) => { return observable((emit) => { + const onStarted = (cwd: string) => { + emit.next({ type: "started", cwd }) + } + const onData = (data: string) => { emit.next({ type: "data", data }) } const onExit = (exitCode: number, signal?: number) => { emit.next({ type: "exit", exitCode, signal }) - emit.complete() } + terminalManager.on(`started:${paneId}`, onStarted) terminalManager.on(`data:${paneId}`, onData) terminalManager.on(`exit:${paneId}`, onExit) return () => { + terminalManager.off(`started:${paneId}`, onStarted) terminalManager.off(`data:${paneId}`, onData) terminalManager.off(`exit:${paneId}`, onExit) } }) }), + + // ===== Port Management ===== + + /** + * Get all detected ports across all terminal sessions + */ + getAllPorts: publicProcedure.query(() => { + return portManager.getAllPorts() + }), + + /** + * Get ports for a specific workspace + */ + getPortsByWorkspace: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + return portManager.getPortsByWorkspace(input.workspaceId) + }), + + /** + * Kill a process by PID (sends SIGTERM, then SIGKILL after timeout) + */ + killProcessByPid: publicProcedure + .input(z.object({ pid: z.number() })) + .mutation(async ({ input }) => { + try { + process.kill(input.pid, "SIGTERM") + // Give it 2 seconds to terminate gracefully, then force kill + await new Promise((resolve) => setTimeout(resolve, 2000)) + try { + // Check if process is still running + process.kill(input.pid, 0) + // Still running, force kill + process.kill(input.pid, "SIGKILL") + } catch { + // Process already exited, which is good + } + return { success: true } + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + err instanceof Error ? err.message : "Failed to kill process", + }) + } + }), + + /** + * Subscribe to port changes (add/remove events) + */ + portChanges: publicProcedure.subscription(() => { + return observable<{ type: "add" | "remove"; port: DetectedPort }>( + (emit) => { + const onPortAdd = (port: DetectedPort) => { + emit.next({ type: "add", port }) + } + + const onPortRemove = (port: DetectedPort) => { + emit.next({ type: "remove", port }) + } + + portManager.on("port:add", onPortAdd) + portManager.on("port:remove", onPortRemove) + + return () => { + portManager.off("port:add", onPortAdd) + portManager.off("port:remove", onPortRemove) + } + }, + ) + }), }) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index ec634bcc..69274a62 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -423,6 +423,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): contextIsolation: true, sandbox: false, // Required for electron-trpc webSecurity: true, + webviewTag: true, // Enable tag for preview sidebar partition: "persist:main", // Use persistent session for cookies }, }) diff --git a/src/renderer/components/ui/canvas-icons.tsx b/src/renderer/components/ui/canvas-icons.tsx index defc2657..90ce375d 100644 --- a/src/renderer/components/ui/canvas-icons.tsx +++ b/src/renderer/components/ui/canvas-icons.tsx @@ -2399,7 +2399,7 @@ export function VolumeIcon({ className }: { className?: string }) { fill="none" className={className} > - + - + void } const DEFAULT_MIN_WIDTH = 200 @@ -51,6 +53,7 @@ export function ResizableSidebar({ disableClickToClose = false, showResizeTooltip = false, style, + onResizeChange, }: ResizableSidebarProps) { const [sidebarWidth, setSidebarWidth] = useAtom(widthAtom) @@ -74,6 +77,11 @@ export function ResizableSidebar({ // Use local width during resize, otherwise use persisted width const currentWidth = localWidth ?? sidebarWidth + // Notify parent of resize state changes (useful for blocking webviews) + useEffect(() => { + onResizeChange?.(isResizing) + }, [isResizing, onResizeChange]) + // Calculate tooltip position dynamically based on sidebar position const tooltipPosition = useMemo(() => { if (!tooltipY || !sidebarRef.current) return null diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 3590de3b..dd89b0eb 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -229,6 +229,21 @@ export const agentsPreviewSidebarOpenAtom = atomWithWindowStorage( { getOnInit: true }, ) +// New Preview sidebar (right) - simple empty preview panel +export const newPreviewSidebarOpenAtom = atomWithStorage( + "new-preview-sidebar-open", + false, + undefined, + { getOnInit: true }, +) + +export const newPreviewSidebarWidthAtom = atomWithStorage( + "new-preview-sidebar-width", + 400, + undefined, + { getOnInit: true }, +) + // Diff sidebar (right) width (global - same width for all chats) export const agentsDiffSidebarWidthAtom = atomWithStorage( "agents-diff-sidebar-width", @@ -773,3 +788,13 @@ export const workspaceDiffCacheAtomFamily = atomFamily((chatId: string) => }, ), ) + +// Callback atom for preview element selection +// Set by ChatViewInner, called by ChatView when element is selected in PreviewSidebar +export type AddPreviewElementContextFn = ( + html: string, + componentName: string | null, + filePath: string | null +) => void + +export const addPreviewElementContextFnAtom = atom(null) diff --git a/src/renderer/features/agents/hooks/use-preview-element-selection.ts b/src/renderer/features/agents/hooks/use-preview-element-selection.ts new file mode 100644 index 00000000..4f74e373 --- /dev/null +++ b/src/renderer/features/agents/hooks/use-preview-element-selection.ts @@ -0,0 +1,81 @@ +import { useState, useCallback, useRef } from "react" +import { + type PreviewElementContext, + createTextPreview, +} from "../lib/queue-utils" + +export interface UsePreviewElementSelectionReturn { + previewElementContexts: PreviewElementContext[] + addPreviewElementContext: ( + html: string, + componentName: string | null, + filePath: string | null + ) => void + removePreviewElementContext: (id: string) => void + clearPreviewElementContexts: () => void + // Ref for accessing current value in callbacks without re-renders + previewElementContextsRef: React.RefObject + // Direct state setter for restoring from draft + setPreviewElementContextsFromDraft: (contexts: PreviewElementContext[]) => void +} + +export function usePreviewElementSelection(): UsePreviewElementSelectionReturn { + const [previewElementContexts, setPreviewElementContexts] = useState< + PreviewElementContext[] + >([]) + const previewElementContextsRef = useRef([]) + + // Keep ref in sync with state + previewElementContextsRef.current = previewElementContexts + + const addPreviewElementContext = useCallback( + (html: string, componentName: string | null, filePath: string | null) => { + const trimmedHtml = html.trim() + if (!trimmedHtml) return + + // Prevent duplicates - check if same HTML already exists + const isDuplicate = previewElementContextsRef.current.some( + (ctx) => ctx.html === trimmedHtml + ) + if (isDuplicate) return + + const newContext: PreviewElementContext = { + id: `pec_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + html: trimmedHtml, + componentName, + filePath, + preview: createTextPreview(trimmedHtml), + createdAt: new Date(), + } + + setPreviewElementContexts((prev) => [...prev, newContext]) + }, + [] + ) + + const removePreviewElementContext = useCallback((id: string) => { + setPreviewElementContexts((prev) => prev.filter((ctx) => ctx.id !== id)) + }, []) + + const clearPreviewElementContexts = useCallback(() => { + setPreviewElementContexts([]) + }, []) + + // Direct state setter for restoring from draft + const setPreviewElementContextsFromDraft = useCallback( + (contexts: PreviewElementContext[]) => { + setPreviewElementContexts(contexts) + previewElementContextsRef.current = contexts + }, + [] + ) + + return { + previewElementContexts, + addPreviewElementContext, + removePreviewElementContext, + clearPreviewElementContexts, + previewElementContextsRef, + setPreviewElementContextsFromDraft, + } +} diff --git a/src/renderer/features/agents/lib/agents-actions.ts b/src/renderer/features/agents/lib/agents-actions.ts index f75f30e2..4b611b1e 100644 --- a/src/renderer/features/agents/lib/agents-actions.ts +++ b/src/renderer/features/agents/lib/agents-actions.ts @@ -19,6 +19,7 @@ export interface AgentActionContext { // UI states setSidebarOpen?: (open: boolean | ((prev: boolean) => boolean)) => void + setPreviewOpen?: (open: boolean | ((prev: boolean) => boolean)) => void setSettingsDialogOpen?: (open: boolean) => void setSettingsActiveTab?: (tab: SettingsTab) => void toggleChatSearch?: () => void @@ -119,6 +120,18 @@ const toggleChatSearchAction: AgentActionDefinition = { }, } +const togglePreviewAction: AgentActionDefinition = { + id: "toggle-preview", + label: "Toggle preview", + description: "Show/hide preview sidebar", + category: "view", + hotkey: ["cmd+r"], + handler: async (context) => { + context.setPreviewOpen?.((prev) => !prev) + return { success: true } + }, +} + // ============================================================================ // ACTION REGISTRY // ============================================================================ @@ -129,6 +142,7 @@ export const AGENT_ACTIONS: Record = { "open-settings": openSettingsAction, "toggle-sidebar": toggleSidebarAction, "toggle-chat-search": toggleChatSearchAction, + "toggle-preview": togglePreviewAction, } export function getAgentAction(id: string): AgentActionDefinition | undefined { diff --git a/src/renderer/features/agents/lib/agents-hotkeys-manager.ts b/src/renderer/features/agents/lib/agents-hotkeys-manager.ts index 99922a03..d697f61c 100644 --- a/src/renderer/features/agents/lib/agents-hotkeys-manager.ts +++ b/src/renderer/features/agents/lib/agents-hotkeys-manager.ts @@ -26,6 +26,7 @@ const SHORTCUT_TO_ACTION_MAP: Record = { "show-shortcuts": "open-shortcuts", "open-settings": "open-settings", "toggle-sidebar": "toggle-sidebar", + "toggle-details": "toggle-details", "undo-archive": "undo-archive", "new-workspace": "create-new-agent", "search-workspaces": "search-workspaces", @@ -43,6 +44,7 @@ const SHORTCUT_TO_ACTION_MAP: Record = { "stop-generation": "stop-generation", "switch-model": "switch-model", "toggle-terminal": "toggle-terminal", + "toggle-preview": "toggle-preview", "open-diff": "open-diff", "create-pr": "create-pr", } @@ -100,11 +102,13 @@ function matchesHotkey(e: KeyboardEvent, hotkey: string): boolean { export interface AgentsHotkeysManagerConfig { setSelectedChatId?: (id: string | null) => void setSidebarOpen?: (open: boolean | ((prev: boolean) => boolean)) => void + setPreviewOpen?: (open: boolean | ((prev: boolean) => boolean)) => void setSettingsDialogOpen?: (open: boolean) => void setSettingsActiveTab?: (tab: SettingsTab) => void toggleChatSearch?: () => void selectedChatId?: string | null customHotkeysConfig?: CustomHotkeysConfig + isDesktop?: boolean } export interface UseAgentsHotkeysOptions { @@ -129,6 +133,7 @@ export function useAgentsHotkeys( (): AgentActionContext => ({ setSelectedChatId: config.setSelectedChatId, setSidebarOpen: config.setSidebarOpen, + setPreviewOpen: config.setPreviewOpen, setSettingsDialogOpen: config.setSettingsDialogOpen, setSettingsActiveTab: config.setSettingsActiveTab, toggleChatSearch: config.toggleChatSearch, @@ -137,6 +142,7 @@ export function useAgentsHotkeys( [ config.setSelectedChatId, config.setSidebarOpen, + config.setPreviewOpen, config.setSettingsDialogOpen, config.setSettingsActiveTab, config.toggleChatSearch, @@ -228,11 +234,22 @@ export function useAgentsHotkeys( handleHotkeyAction("toggle-chat-search") return } + + // Check toggle-preview hotkey (desktop only) + if (config.isDesktop) { + const togglePreviewHotkey = getHotkeyForAction("toggle-preview") + if (togglePreviewHotkey && matchesHotkey(e, togglePreviewHotkey)) { + e.preventDefault() + e.stopPropagation() + handleHotkeyAction("toggle-preview") + return + } + } } window.addEventListener("keydown", handleKeyDown, true) return () => window.removeEventListener("keydown", handleKeyDown, true) - }, [enabled, handleHotkeyAction, getHotkeyForAction]) + }, [enabled, handleHotkeyAction, getHotkeyForAction, config.isDesktop]) // General hotkey handler for remaining actions const actionsWithHotkeys = useMemo( @@ -244,7 +261,8 @@ export function useAgentsHotkeys( action.id !== "toggle-sidebar" && action.id !== "open-shortcuts" && action.id !== "open-settings" && - action.id !== "toggle-chat-search", + action.id !== "toggle-chat-search" && + action.id !== "toggle-preview", ), [], ) diff --git a/src/renderer/features/agents/lib/drafts.ts b/src/renderer/features/agents/lib/drafts.ts index 01c4e644..a87851c3 100644 --- a/src/renderer/features/agents/lib/drafts.ts +++ b/src/renderer/features/agents/lib/drafts.ts @@ -3,7 +3,7 @@ import type { UploadedImage, UploadedFile, } from "../hooks/use-agents-file-upload" -import type { SelectedTextContext } from "./queue-utils" +import type { SelectedTextContext, PreviewElementContext } from "./queue-utils" // Constants export const DRAFTS_STORAGE_KEY = "agent-drafts-global" @@ -38,6 +38,15 @@ export interface DraftTextContext { createdAt: string // ISO string instead of Date } +export interface DraftPreviewElementContext { + id: string + html: string + componentName: string | null + filePath: string | null + preview: string + createdAt: string // ISO string instead of Date +} + // Types export interface DraftContent { text: string @@ -45,6 +54,7 @@ export interface DraftContent { images?: DraftImage[] files?: DraftFile[] textContexts?: DraftTextContext[] + previewElementContexts?: DraftPreviewElementContext[] } export interface DraftProject { @@ -413,6 +423,25 @@ export function toDraftTextContext( } } +/** + * Convert PreviewElementContext to DraftPreviewElementContext + */ +export function toDraftPreviewElementContext( + ctx: PreviewElementContext +): DraftPreviewElementContext { + return { + id: ctx.id, + html: ctx.html, + componentName: ctx.componentName, + filePath: ctx.filePath, + preview: ctx.preview, + createdAt: + ctx.createdAt instanceof Date + ? ctx.createdAt.toISOString() + : String(ctx.createdAt), + } +} + /** * Revoke blob URLs associated with a draft item */ @@ -519,6 +548,22 @@ export function fromDraftTextContext( } } +/** + * Restore PreviewElementContext from DraftPreviewElementContext + */ +export function fromDraftPreviewElementContext( + draft: DraftPreviewElementContext +): PreviewElementContext { + return { + id: draft.id, + html: draft.html, + componentName: draft.componentName, + filePath: draft.filePath, + preview: draft.preview, + createdAt: new Date(draft.createdAt), + } +} + /** * Full draft data including attachments */ @@ -527,6 +572,7 @@ export interface FullDraftData { images: UploadedImage[] files: UploadedFile[] textContexts: SelectedTextContext[] + previewElementContexts: PreviewElementContext[] } /** @@ -553,6 +599,8 @@ export function getSubChatDraftFull( ?.map(fromDraftFile) .filter((f): f is UploadedFile => f !== null) ?? [], textContexts: draft.textContexts?.map(fromDraftTextContext) ?? [], + previewElementContexts: + draft.previewElementContexts?.map(fromDraftPreviewElementContext) ?? [], } } @@ -567,6 +615,7 @@ export async function saveSubChatDraftWithAttachments( images?: UploadedImage[] files?: UploadedFile[] textContexts?: SelectedTextContext[] + previewElementContexts?: PreviewElementContext[] } ): Promise<{ success: boolean; error?: string }> { const globalDrafts = loadGlobalDrafts() @@ -576,7 +625,8 @@ export async function saveSubChatDraftWithAttachments( text.trim() || (options?.images?.length ?? 0) > 0 || (options?.files?.length ?? 0) > 0 || - (options?.textContexts?.length ?? 0) > 0 + (options?.textContexts?.length ?? 0) > 0 || + (options?.previewElementContexts?.length ?? 0) > 0 if (!hasContent) { delete globalDrafts[key] @@ -597,6 +647,8 @@ export async function saveSubChatDraftWithAttachments( : [] const draftTextContexts = options?.textContexts?.map(toDraftTextContext) ?? [] + const draftPreviewElementContexts = + options?.previewElementContexts?.map(toDraftPreviewElementContext) ?? [] const draft: DraftContent = { text, @@ -604,6 +656,9 @@ export async function saveSubChatDraftWithAttachments( ...(draftImages.length > 0 && { images: draftImages }), ...(draftFiles.length > 0 && { files: draftFiles }), ...(draftTextContexts.length > 0 && { textContexts: draftTextContexts }), + ...(draftPreviewElementContexts.length > 0 && { + previewElementContexts: draftPreviewElementContexts, + }), } // Check storage limits before saving diff --git a/src/renderer/features/agents/lib/queue-utils.ts b/src/renderer/features/agents/lib/queue-utils.ts index 4e8a2d1f..20a666e7 100644 --- a/src/renderer/features/agents/lib/queue-utils.ts +++ b/src/renderer/features/agents/lib/queue-utils.ts @@ -55,6 +55,23 @@ export interface QueuedDiffTextContext { lineType?: "old" | "new" } +// Element context selected from preview sidebar WebView +export interface PreviewElementContext { + id: string + html: string // Raw HTML of selected element + componentName: string | null // React component name if available + filePath: string | null // Source file path if available + preview: string // Truncated HTML for display + createdAt: Date +} + +export interface QueuedPreviewElementContext { + id: string + html: string + componentName: string | null + filePath: string | null +} + export type AgentQueueItem = { id: string message: string // Serialized value with @[id] tokens for mentions @@ -62,6 +79,7 @@ export type AgentQueueItem = { files?: QueuedFile[] textContexts?: QueuedTextContext[] diffTextContexts?: QueuedDiffTextContext[] + previewElementContexts?: QueuedPreviewElementContext[] timestamp: Date status: "pending" | "processing" } @@ -76,7 +94,8 @@ export function createQueueItem( images?: QueuedImage[], files?: QueuedFile[], textContexts?: QueuedTextContext[], - diffTextContexts?: QueuedDiffTextContext[] + diffTextContexts?: QueuedDiffTextContext[], + previewElementContexts?: QueuedPreviewElementContext[] ): AgentQueueItem { return { id, @@ -85,6 +104,7 @@ export function createQueueItem( files: files && files.length > 0 ? files : undefined, textContexts: textContexts && textContexts.length > 0 ? textContexts : undefined, diffTextContexts: diffTextContexts && diffTextContexts.length > 0 ? diffTextContexts : undefined, + previewElementContexts: previewElementContexts && previewElementContexts.length > 0 ? previewElementContexts : undefined, timestamp: new Date(), status: "pending", } @@ -155,6 +175,16 @@ export function toQueuedDiffTextContext(ctx: DiffTextContext): QueuedDiffTextCon } } +// Helper to convert PreviewElementContext to QueuedPreviewElementContext +export function toQueuedPreviewElementContext(ctx: PreviewElementContext): QueuedPreviewElementContext { + return { + id: ctx.id, + html: ctx.html, + componentName: ctx.componentName, + filePath: ctx.filePath, + } +} + // Helper to create a truncated preview from text export function createTextPreview(text: string, maxLength: number = 50): string { const trimmed = text.trim().replace(/\s+/g, " ") diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 821df87c..02eb0310 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -14,6 +14,7 @@ import { CursorIcon, ExpandIcon, IconCloseSidebarRight, + IconDoubleChevronRight, IconOpenSidebarRight, IconSpinner, IconTextUndo, @@ -39,6 +40,7 @@ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import { ArrowDown, ChevronDown, + Eye, ListTree, TerminalSquare } from "lucide-react" @@ -121,6 +123,7 @@ import { setLoading, subChatFilesAtom, undoStackAtom, + addPreviewElementContextFnAtom, type SelectedCommit } from "../atoms" import { AgentSendButton } from "../components/agent-send-button" @@ -133,6 +136,7 @@ import { useDesktopNotifications } from "../hooks/use-desktop-notifications" import { useFocusInputOnEnter } from "../hooks/use-focus-input-on-enter" import { useHaptic } from "../hooks/use-haptic" import { useTextContextSelection } from "../hooks/use-text-context-selection" +import { usePreviewElementSelection } from "../hooks/use-preview-element-selection" import { usePastedTextFiles } from "../hooks/use-pasted-text-files" import { useToggleFocusOnCmdEsc } from "../hooks/use-toggle-focus-on-cmd-esc" import { @@ -191,6 +195,7 @@ import { generateCommitToPrMessage, generatePrMessage, generateReviewMessage } f import { ChatInputArea } from "./chat-input-area" import { IsolatedMessagesSection } from "./isolated-messages-section" import { DetailsSidebar } from "../../details-sidebar/details-sidebar" +import { PreviewSidebar, previewSidebarOpenAtom } from "../../preview-sidebar" import { detailsSidebarOpenAtom, unifiedSidebarEnabledAtom, @@ -2183,6 +2188,16 @@ const ChatViewInner = memo(function ChatViewInner({ pastedTextsRef, } = usePastedTextFiles(subChatId) + // Preview element contexts (elements selected from preview sidebar) + const { + previewElementContexts, + addPreviewElementContext, + removePreviewElementContext, + clearPreviewElementContexts, + previewElementContextsRef, + setPreviewElementContextsFromDraft, + } = usePreviewElementSelection() + // File contents cache - stores content for file mentions (keyed by mentionId) // This content gets added to the prompt when sending, without showing a separate card const fileContentsRef = useRef>(new Map()) @@ -2257,6 +2272,12 @@ const ChatViewInner = memo(function ChatViewInner({ } else { clearTextContexts() } + // Restore preview element contexts + if (savedDraft.previewElementContexts.length > 0) { + setPreviewElementContextsFromDraft(savedDraft.previewElementContexts) + } else { + clearPreviewElementContexts() + } } else if ( prevSubChatIdForDraftRef.current && prevSubChatIdForDraftRef.current !== subChatId @@ -2265,6 +2286,7 @@ const ChatViewInner = memo(function ChatViewInner({ editorRef.current?.clear() clearAll() clearTextContexts() + clearPreviewElementContexts() } prevSubChatIdForDraftRef.current = subChatId @@ -2274,8 +2296,10 @@ const ChatViewInner = memo(function ChatViewInner({ setImagesFromDraft, setFilesFromDraft, setTextContextsFromDraft, + setPreviewElementContextsFromDraft, clearAll, clearTextContexts, + clearPreviewElementContexts, ]) // Use subChatId as stable key to prevent HMR-induced duplicate resume requests @@ -2328,6 +2352,23 @@ const ChatViewInner = memo(function ChatViewInner({ ) }, [subChatId]) + // Handler for preview element selection from preview sidebar + const handlePreviewElementSelect = useCallback( + (html: string, componentName: string | null, filePath: string | null) => { + addPreviewElementContext(html, componentName, filePath) + // Focus chat input after adding context + editorRef.current?.focus() + }, + [addPreviewElementContext] + ) + + // Share the addPreviewElementContext function with ChatView via atom + const setAddPreviewElementContextFn = useSetAtom(addPreviewElementContextFnAtom) + useEffect(() => { + setAddPreviewElementContextFn(() => addPreviewElementContext) + return () => setAddPreviewElementContextFn(null) + }, [addPreviewElementContext, setAddPreviewElementContextFn]) + // Wrapper for addTextContext that handles TextSelectionSource const addTextContext = useCallback((text: string, source: TextSelectionSource) => { if (source.type === "assistant-message") { @@ -3295,6 +3336,7 @@ const ChatViewInner = memo(function ChatViewInner({ } clearAll() clearTextContexts() + clearPreviewElementContexts() return } @@ -3430,6 +3472,7 @@ const ChatViewInner = memo(function ChatViewInner({ clearAll() clearTextContexts() clearDiffTextContexts() + clearPreviewElementContexts() clearPastedTexts() clearFileContents() @@ -3492,6 +3535,7 @@ const ChatViewInner = memo(function ChatViewInner({ onAutoRename, clearAll, clearTextContexts, + clearPreviewElementContexts, clearPastedTexts, teamId, addToQueue, @@ -4087,6 +4131,8 @@ const ChatViewInner = memo(function ChatViewInner({ pastedTexts={pastedTexts} onAddPastedText={addPastedText} onRemovePastedText={removePastedText} + previewElementContexts={previewElementContexts} + onRemovePreviewElementContext={removePreviewElementContext} onCacheFileContent={cacheFileContent} messageTokenData={messageTokenData} subChatId={subChatId} @@ -4162,12 +4208,25 @@ export function ChatView({ const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) const { notifyAgentComplete } = useDesktopNotifications() + // Get addPreviewElementContext function from ChatViewInner via atom + const addPreviewElementContextFn = useAtomValue(addPreviewElementContextFnAtom) + const handlePreviewElementSelect = useCallback( + (html: string, componentName: string | null, filePath: string | null) => { + addPreviewElementContextFn?.(html, componentName, filePath) + }, + [addPreviewElementContextFn] + ) + // Check if any chat has unseen changes const hasAnyUnseenChanges = unseenChanges.size > 0 const [, forceUpdate] = useState({}) const [isPreviewSidebarOpen, setIsPreviewSidebarOpen] = useAtom( agentsPreviewSidebarOpenAtom, ) + // New Preview sidebar state (with dev server and browser preview) + const [isNewPreviewSidebarOpen, setIsNewPreviewSidebarOpen] = useAtom( + previewSidebarOpenAtom, + ) // Per-chat diff sidebar state - each chat remembers its own open/close state const diffSidebarAtom = useMemo( () => diffSidebarOpenAtomFamily(chatId), @@ -6074,6 +6133,23 @@ Make sure to preserve all functionality from both branches when resolving confli ))} + {/* New Preview Button - shows when preview sidebar is closed (desktop only) */} + {isDesktop && !isMobileFullscreen && !isNewPreviewSidebarOpen && ( + + + + + Preview + + )} {/* Overview/Terminal Button - shows when sidebar is closed and worktree exists (desktop only) */} {!isMobileFullscreen && worktreePath && ( @@ -6445,6 +6521,15 @@ Make sure to preserve all functionality from both branches when resolving confli /> )} + {/* New Preview Sidebar - with dev server and browser preview (desktop only) */} + {isDesktop && !isMobileFullscreen && worktreePath && ( + + )} + {/* Unified Details Sidebar - combines all right sidebars into one (rightmost) */} {isUnifiedSidebarEnabled && !isMobileFullscreen && worktreePath && ( Promise onRemovePastedText?: (id: string) => void + // Preview element context from selected element in preview sidebar + previewElementContexts?: PreviewElementContext[] + onRemovePreviewElementContext?: (id: string) => void // Callback to cache file content for dropped text files (content added to prompt on send) onCacheFileContent?: (mentionId: string, content: string) => void // Pre-computed token data for context indicator (avoids passing messages array) @@ -198,6 +202,7 @@ function arePropsEqual(prevProps: ChatInputAreaProps, nextProps: ChatInputAreaPr prevProps.onRemoveTextContext !== nextProps.onRemoveTextContext || prevProps.onAddPastedText !== nextProps.onAddPastedText || prevProps.onRemovePastedText !== nextProps.onRemovePastedText || + prevProps.onRemovePreviewElementContext !== nextProps.onRemovePreviewElementContext || prevProps.onCacheFileContent !== nextProps.onCacheFileContent || prevProps.onInputContentChange !== nextProps.onInputContentChange || prevProps.onSubmitWithQuestionAnswer !== nextProps.onSubmitWithQuestionAnswer @@ -268,6 +273,18 @@ function arePropsEqual(prevProps: ChatInputAreaProps, nextProps: ChatInputAreaPr } } + // Compare previewElementContexts array - by length and ids + const prevPreview = prevProps.previewElementContexts || [] + const nextPreview = nextProps.previewElementContexts || [] + if (prevPreview.length !== nextPreview.length) { + return false + } + for (let i = 0; i < prevPreview.length; i++) { + if (prevPreview[i]?.id !== nextPreview[i]?.id) { + return false + } + } + // Compare messageTokenData - only re-render when token counts actually change // This is much more stable than comparing messages array reference if ( @@ -331,6 +348,8 @@ export const ChatInputArea = memo(function ChatInputArea({ pastedTexts = [], onAddPastedText, onRemovePastedText, + previewElementContexts = [], + onRemovePreviewElementContext, onCacheFileContent, messageTokenData, subChatId, @@ -454,18 +473,20 @@ export const ChatInputArea = memo(function ChatInputArea({ images.length > 0 || files.length > 0 || textContexts.length > 0 || - (diffTextContexts?.length ?? 0) > 0 + (diffTextContexts?.length ?? 0) > 0 || + previewElementContexts.length > 0 if (hasContent) { await saveSubChatDraftWithAttachments(chatId, subChatIdValue, draft, { images, files, textContexts, + previewElementContexts, }) } else { clearSubChatDraft(chatId, subChatIdValue) } - }, [editorRef, images, files, textContexts, diffTextContexts]) + }, [editorRef, images, files, textContexts, diffTextContexts, previewElementContexts]) // Content change handler const handleContentChange = useCallback((newHasContent: boolean) => { @@ -481,7 +502,7 @@ export const ChatInputArea = memo(function ChatInputArea({ const handleEditorSubmit = useCallback(async () => { const inputValue = editorRef.current?.getValue() || "" const hasText = inputValue.trim().length > 0 - const hasAttachments = images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0 + const hasAttachments = images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0 || previewElementContexts.length > 0 if (!hasText && !hasAttachments && queueLength > 0 && onSendFromQueue && firstQueueItemId) { // Input empty, queue has items - stop stream and send from queue @@ -490,7 +511,7 @@ export const ChatInputArea = memo(function ChatInputArea({ } else { onSend() } - }, [editorRef, images, files, textContexts, diffTextContexts, queueLength, onSendFromQueue, firstQueueItemId, onStop, onSend]) + }, [editorRef, images, files, textContexts, diffTextContexts, previewElementContexts, queueLength, onSendFromQueue, firstQueueItemId, onStop, onSend]) // Mention select handler const handleMentionSelect = useCallback((mention: FileMentionOption) => { @@ -744,7 +765,7 @@ export const ChatInputArea = memo(function ChatInputArea({ maxHeight={200} onSubmit={onSend} contextItems={ - images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0 || pastedTexts.length > 0 ? ( + images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0 || pastedTexts.length > 0 || previewElementContexts.length > 0 ? (
{(() => { // Build allImages array for gallery navigation @@ -809,6 +830,16 @@ export const ChatInputArea = memo(function ChatInputArea({ onRemove={onRemovePastedText ? () => onRemovePastedText(pt.id) : undefined} /> ))} + {previewElementContexts.map((pec) => ( + onRemovePreviewElementContext(pec.id) : undefined} + /> + ))}
) : null } @@ -1189,10 +1220,11 @@ export const ChatInputArea = memo(function ChatInputArea({ files.length === 0 && textContexts.length === 0 && (diffTextContexts?.length ?? 0) === 0 && + previewElementContexts.length === 0 && queueLength === 0) || isUploading } - hasContent={hasContent || images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0} + hasContent={hasContent || images.length > 0 || files.length > 0 || textContexts.length > 0 || (diffTextContexts?.length ?? 0) > 0 || previewElementContexts.length > 0} onClick={() => { // If input is empty and queue has items, send first queue item if (!hasContent && images.length === 0 && files.length === 0 && queueLength > 0 && onSendFromQueue && firstQueueItemId) { diff --git a/src/renderer/features/agents/ui/agent-preview-element-item.tsx b/src/renderer/features/agents/ui/agent-preview-element-item.tsx new file mode 100644 index 00000000..b6a39a8c --- /dev/null +++ b/src/renderer/features/agents/ui/agent-preview-element-item.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useState } from "react" +import { X, Code2 } from "lucide-react" + +interface AgentPreviewElementItemProps { + html: string + componentName: string | null + filePath: string | null + preview: string + onRemove?: () => void +} + +export function AgentPreviewElementItem({ + html, + componentName, + filePath, + preview, + onRemove, +}: AgentPreviewElementItemProps) { + const [isHovered, setIsHovered] = useState(false) + + // Display title: component name if available, otherwise "Element" + const title = componentName || "Element" + + // Subtitle: file path if available, otherwise try to extract tag name from preview + const getSubtitle = () => { + if (filePath) { + return filePath.split("/").pop() || filePath + } + // Try to extract tag name from HTML preview + const match = preview.match(/<(\w+)/) + return match ? `<${match[1]}>` : "HTML" + } + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Icon container */} +
+ +
+ + {/* Text content */} +
+ + {title} + + + {getSubtitle()} + +
+ + {/* Remove button */} + {onRemove && ( + + )} +
+ ) +} diff --git a/src/renderer/features/details-sidebar/sections/info-section.tsx b/src/renderer/features/details-sidebar/sections/info-section.tsx index 14d6c03a..698b16a2 100644 --- a/src/renderer/features/details-sidebar/sections/info-section.tsx +++ b/src/renderer/features/details-sidebar/sections/info-section.tsx @@ -72,7 +72,7 @@ function PropertyRow({ {/* Value column - flexible */}
{copyable ? ( - + {valueSpan} diff --git a/src/renderer/features/layout/agents-layout.tsx b/src/renderer/features/layout/agents-layout.tsx index b7d81789..69b0cc26 100644 --- a/src/renderer/features/layout/agents-layout.tsx +++ b/src/renderer/features/layout/agents-layout.tsx @@ -14,6 +14,7 @@ import { customHotkeysAtom, } from "../../lib/atoms" import { selectedAgentChatIdAtom, selectedProjectAtom } from "../agents/atoms" +import { previewSidebarOpenAtom } from "../preview-sidebar" import { trpc } from "../../lib/trpc" import { useAgentsHotkeys } from "../agents/lib/agents-hotkeys-manager" import { toggleSearchAtom } from "../agents/search" @@ -210,6 +211,9 @@ export function AgentsLayout() { // Chat search toggle const toggleChatSearch = useSetAtom(toggleSearchAtom) + // Preview sidebar toggle + const setPreviewOpen = useSetAtom(previewSidebarOpenAtom) + // Custom hotkeys config const customHotkeysConfig = useAtomValue(customHotkeysAtom) @@ -217,11 +221,13 @@ export function AgentsLayout() { useAgentsHotkeys({ setSelectedChatId, setSidebarOpen, + setPreviewOpen, setSettingsDialogOpen: setSettingsOpen, setSettingsActiveTab, toggleChatSearch, selectedChatId, customHotkeysConfig, + isDesktop, }) const handleCloseSidebar = useCallback(() => { diff --git a/src/renderer/features/preview-sidebar/index.ts b/src/renderer/features/preview-sidebar/index.ts new file mode 100644 index 00000000..15d5acf2 --- /dev/null +++ b/src/renderer/features/preview-sidebar/index.ts @@ -0,0 +1,7 @@ +export { + PreviewSidebar, + previewSidebarOpenAtom, + previewSidebarWidthAtom, + runningDevServersAtom, +} from "./preview-sidebar" +export type { DetectedUrl, PreviewTerminalState } from "./types" diff --git a/src/renderer/features/preview-sidebar/preview-sidebar.tsx b/src/renderer/features/preview-sidebar/preview-sidebar.tsx new file mode 100644 index 00000000..e366046c --- /dev/null +++ b/src/renderer/features/preview-sidebar/preview-sidebar.tsx @@ -0,0 +1,833 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { atom, useAtom, useSetAtom } from "jotai" +import { atomWithStorage, atomFamily } from "jotai/utils" +import { + Play, + Square, + ChevronDown, + RefreshCw, + ExternalLink, + ChevronLeft, + ChevronRight, + Bug, + Copy, + MousePointer2, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { ResizableSidebar } from "@/components/ui/resizable-sidebar" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { IconDoubleChevronRight } from "@/components/ui/icons" +import { trpc } from "@/lib/trpc" +import { parseAndMergeUrls } from "./url-parser" +import { TerminalOutput } from "./terminal-output" +import type { DetectedUrl } from "./types" +import type { TerminalStreamEvent } from "../terminal/types" + +// ============================================================================ +// Helpers +// ============================================================================ + +const isLocalUrl = (url: string): boolean => { + try { + const { hostname } = new URL(url) + return hostname === "localhost" || hostname === "127.0.0.1" + } catch { + return false + } +} + +// ============================================================================ +// Atoms +// ============================================================================ + +export const previewSidebarOpenAtom = atomWithStorage( + "preview-sidebar-open", + false, + undefined, + { getOnInit: true } +) + +export const previewSidebarWidthAtom = atomWithStorage( + "preview-sidebar-width", + 500, + undefined, + { getOnInit: true } +) + +export const previewSplitPositionAtom = atomWithStorage( + "preview-split-position", + 60, + undefined, + { getOnInit: true } +) + +// Track which chats have running dev servers (for sidebar indicator) +export const runningDevServersAtom = atom>(new Set()) + +// Per-chat preview state (UI state only - backend is source of truth for running) +interface PreviewState { + detectedUrls: DetectedUrl[] + selectedUrl: string | null + output: string[] +} + +const initialPreviewState: PreviewState = { + detectedUrls: [], + selectedUrl: null, + output: [], +} + +export const previewStateFamily = atomFamily((chatId: string) => + atom(initialPreviewState) +) + +// ============================================================================ +// Component +// ============================================================================ + +interface PreviewSidebarProps { + chatId: string + worktreePath: string | null + onElementSelect?: ( + html: string, + componentName: string | null, + filePath: string | null + ) => void +} + +function getPaneId(chatId: string): string { + return `${chatId}:preview:dev` +} + +// React Grab injection script +const REACT_GRAB_INJECT_SCRIPT = ` +(function() { + if (window.__REACT_GRAB_LOADED__) { + if (window.__REACT_GRAB_API__) { + window.__REACT_GRAB_API__.activate(); + } + return; + } + + const script = document.createElement('script'); + script.src = 'https://unpkg.com/react-grab/dist/index.global.js'; + script.onload = function() { + window.__REACT_GRAB_LOADED__ = true; + + // Wait for React Grab to initialize + setTimeout(function() { + if (window.ReactGrab) { + const api = window.ReactGrab.init ? window.ReactGrab.init() : window.ReactGrab; + window.__REACT_GRAB_API__ = api; + + // Register plugin to capture selections + if (api.registerPlugin) { + api.registerPlugin({ + name: 'element-capture', + hooks: { + onCopySuccess: function(elements, content) { + if (elements && elements.length > 0) { + const el = elements[0]; + // Try to get React component info from React Grab's data + const reactInfo = el._reactGrabInfo || el.__reactGrabData || {}; + const data = { + html: el.outerHTML ? el.outerHTML.slice(0, 10000) : content.slice(0, 10000), + componentName: reactInfo.componentName || reactInfo.name || null, + filePath: reactInfo.filePath || reactInfo.source || null, + }; + console.log('__ELEMENT_SELECTED__:' + JSON.stringify(data)); + } + } + } + }); + } + + if (api.activate) { + api.activate(); + } + } + }, 100); + }; + script.onerror = function() { + console.error('Failed to load React Grab'); + }; + document.head.appendChild(script); +})(); +` + +const REACT_GRAB_DEACTIVATE_SCRIPT = ` +(function() { + if (window.__REACT_GRAB_API__ && window.__REACT_GRAB_API__.deactivate) { + window.__REACT_GRAB_API__.deactivate(); + } +})(); +` + +export function PreviewSidebar({ chatId, worktreePath, onElementSelect }: PreviewSidebarProps) { + const [isOpen, setIsOpen] = useAtom(previewSidebarOpenAtom) + const [splitPosition, setSplitPosition] = useAtom(previewSplitPositionAtom) + + const stateAtom = useMemo(() => previewStateFamily(chatId), [chatId]) + const [state, setState] = useAtom(stateAtom) + + const webviewRef = useRef(null) + const webviewContainerRef = useRef(null) + const panelContainerRef = useRef(null) + + // Track if any resize is happening (blocks webview pointer events) + const [isSplitResizing, setIsSplitResizing] = useState(false) + const [isSidebarResizing, setIsSidebarResizing] = useState(false) + const isAnyResizing = isSplitResizing || isSidebarResizing + + // Element selector state + const [isSelectorActive, setIsSelectorActive] = useState(false) + + const paneId = useMemo(() => getPaneId(chatId), [chatId]) + + // tRPC mutations + const createOrAttach = trpc.terminal.createOrAttach.useMutation() + const write = trpc.terminal.write.useMutation() + const kill = trpc.terminal.kill.useMutation() + const signal = trpc.terminal.signal.useMutation() + + // Running state derived from subscription events (no polling needed) + const [isRunning, setIsRunning] = useState(false) + + // Sync running state to global atom for sidebar indicator + const setRunningDevServers = useSetAtom(runningDevServersAtom) + useEffect(() => { + setRunningDevServers(prev => { + const next = new Set(prev) + if (isRunning) { + next.add(chatId) + } else { + next.delete(chatId) + } + return next + }) + }, [isRunning, chatId, setRunningDevServers]) + + // Track webview readiness via ref (avoids re-renders) + const webviewReadyRef = useRef(false) + + // Check for existing session on mount (handles sidebar reopen while process running) + const { data: initialSession } = trpc.terminal.getSession.useQuery(paneId, { + enabled: isOpen, + staleTime: Infinity, // Only fetch once + refetchOnMount: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + + // Sync initial session state (handles sidebar reopen after process exited) + useEffect(() => { + if (initialSession !== undefined) { + setIsRunning(initialSession?.isAlive ?? false) + } + }, [initialSession]) + + // Handle terminal stream events (started, data, exit) + const handleStream = useCallback( + (event: TerminalStreamEvent) => { + if (event.type === "started") { + setIsRunning(true) + } else if (event.type === "data" && event.data) { + setState(s => { + const output = [...s.output, event.data!].slice(-1000) + const { urls, hasNew } = parseAndMergeUrls(event.data!, s.detectedUrls) + + return { + ...s, + output, + ...(hasNew && { + detectedUrls: urls, + selectedUrl: s.selectedUrl || urls[0]?.url || null, + }), + } + }) + } else if (event.type === "exit") { + setIsRunning(false) + setState(s => ({ + ...s, + output: [...s.output, `\n[Exited with code ${event.exitCode}]`], + })) + } + }, + [setState] + ) + + // Always subscribe when sidebar is open - subscription receives lifecycle events + trpc.terminal.stream.useSubscription(paneId, { + onData: handleStream, + onError: (err) => { + console.error("[PreviewSidebar] Stream error:", err) + setIsRunning(false) + setState(s => ({ ...s, output: [...s.output, `\n[Error: ${err.message}]`] })) + }, + enabled: isOpen, + }) + + // Actions + const handleStart = useCallback(async () => { + if (!worktreePath) return + + setState(s => ({ + ...s, + output: [], + detectedUrls: [], + selectedUrl: null, + })) + + try { + const result = await createOrAttach.mutateAsync({ + paneId, + cwd: worktreePath, + cols: 120, + rows: 30, + }) + + if (result.isNew) { + write.mutate({ paneId, data: "bun run dev\r" }) + } + // isRunning will be set by 'started' event from subscription + } catch (err) { + console.error("[PreviewSidebar] Start failed:", err) + setState(s => ({ + ...s, + output: [...s.output, `[Failed: ${err instanceof Error ? err.message : "Unknown"}]`], + })) + } + }, [worktreePath, paneId, createOrAttach, write, setState]) + + const handleStop = useCallback(async () => { + if (!isRunning) return + + // Optimistically update UI immediately for responsiveness + setIsRunning(false) + + try { + // Send SIGTERM first (more reliable than SIGINT for dev servers) + await signal.mutateAsync({ paneId, signal: "SIGTERM" }) + + // Give process time to terminate gracefully + await new Promise(r => setTimeout(r, 1000)) + + // Force kill if still running + await kill.mutateAsync({ paneId }) + } catch { + // Session might already be dead, that's ok + } + // Final isRunning state will be confirmed by 'exit' event from subscription + }, [isRunning, paneId, signal, kill]) + + const handleRefresh = useCallback(() => { + if (webviewReadyRef.current && webviewRef.current) { + webviewRef.current.reload() + } + }, []) + + const handleOpenExternal = useCallback(() => { + if (state.selectedUrl) { + window.desktopApi?.openExternal(state.selectedUrl) + } + }, [state.selectedUrl]) + + const handleBack = useCallback(() => { + if (webviewReadyRef.current && webviewRef.current) { + webviewRef.current.goBack() + } + }, []) + + const handleForward = useCallback(() => { + if (webviewReadyRef.current && webviewRef.current) { + webviewRef.current.goForward() + } + }, []) + + const handleDevTools = useCallback(() => { + const webview = webviewRef.current + if (!webview) { + console.warn("[PreviewSidebar] DevTools: webview ref is null") + return + } + + try { + if (webview.isDevToolsOpened()) { + webview.closeDevTools() + } else { + // Webview DevTools must open in detached mode (separate window) + webview.openDevTools() + } + } catch (err) { + console.error("[PreviewSidebar] DevTools error:", err) + } + }, []) + + const handleSelectUrl = useCallback( + (url: string) => setState(s => ({ ...s, selectedUrl: url })), + [setState] + ) + + const handleCopyLogs = useCallback(() => { + const logs = state.output.join("") + window.desktopApi?.clipboardWrite(logs) + }, [state.output]) + + const handleToggleSelector = useCallback(() => { + if (!webviewReadyRef.current || !webviewRef.current) return + + if (isSelectorActive) { + setIsSelectorActive(false) + webviewRef.current.executeJavaScript(REACT_GRAB_DEACTIVATE_SCRIPT).catch(() => { + // Ignore errors if webview is not ready + }) + } else { + setIsSelectorActive(true) + webviewRef.current.executeJavaScript(REACT_GRAB_INJECT_SCRIPT).catch(() => { + // Ignore errors if webview is not ready + }) + } + }, [isSelectorActive]) + + // Track the base URL for redirect handling + const baseUrlRef = useRef(null) + + // Ref to access onElementSelect in event handlers without re-creating useEffect + const onElementSelectRef = useRef(onElementSelect) + onElementSelectRef.current = onElementSelect + + // Ref to access setIsSelectorActive in event handlers + const setIsSelectorActiveRef = useRef(setIsSelectorActive) + setIsSelectorActiveRef.current = setIsSelectorActive + + // Ref to access current selectedUrl in webview creation effect + const selectedUrlRef = useRef(state.selectedUrl) + selectedUrlRef.current = state.selectedUrl + + // Track pending URL navigation (for when webview isn't ready yet) + const pendingUrlRef = useRef(null) + + // Create webview once when sidebar opens (stable instance) + useEffect(() => { + const container = webviewContainerRef.current + if (!container) return + + // Create webview element + const webview = document.createElement("webview") as Electron.WebviewTag + webview.setAttribute("allowpopups", "") + webview.setAttribute("partition", "persist:preview") + webview.setAttribute("webpreferences", "devTools=yes") + webview.style.cssText = "flex: 1 1 auto; border: none; min-width: 0; min-height: 0;" + + // Set ref immediately so methods work right away + webviewRef.current = webview + webviewReadyRef.current = false + + const handleDomReady = () => { + webviewReadyRef.current = true + + // If there's a pending URL to navigate to, do it now + const urlToLoad = pendingUrlRef.current || selectedUrlRef.current + if (urlToLoad && webview.src !== urlToLoad) { + baseUrlRef.current = urlToLoad + pendingUrlRef.current = null + webview.src = urlToLoad + } + } + + const handleWillNavigate = (e: Event) => { + const event = e as unknown as { url: string; preventDefault: () => void } + if (!isLocalUrl(event.url)) { + event.preventDefault() + window.desktopApi?.openExternal(event.url) + } + } + + const handleDidNavigate = (e: Event) => { + const event = e as unknown as { url: string } + if (!isLocalUrl(event.url) && baseUrlRef.current) { + window.desktopApi?.openExternal(event.url) + webview.src = baseUrlRef.current + } + } + + const handleNewWindow = (e: Event) => { + const event = e as unknown as { url: string; preventDefault: () => void } + event.preventDefault() + if (isLocalUrl(event.url)) { + webview.src = event.url + } else { + window.desktopApi?.openExternal(event.url) + } + } + + // Handle console messages from webview (for React Grab element selection) + const handleConsoleMessage = (e: Event) => { + const event = e as unknown as { message: string; level: number } + if (event.message.startsWith("__ELEMENT_SELECTED__:")) { + try { + const jsonStr = event.message.slice("__ELEMENT_SELECTED__:".length) + const data = JSON.parse(jsonStr) as { + html: string + componentName: string | null + filePath: string | null + } + onElementSelectRef.current?.(data.html, data.componentName, data.filePath) + // Deactivate selector mode after selection + setIsSelectorActiveRef.current(false) + webview.executeJavaScript(REACT_GRAB_DEACTIVATE_SCRIPT).catch(() => { + // Ignore errors + }) + } catch (err) { + console.error("[PreviewSidebar] Failed to parse element selection:", err) + } + } + } + + webview.addEventListener("dom-ready", handleDomReady) + webview.addEventListener("will-navigate", handleWillNavigate) + webview.addEventListener("did-navigate", handleDidNavigate) + webview.addEventListener("new-window", handleNewWindow) + webview.addEventListener("console-message", handleConsoleMessage) + + // Start with about:blank to trigger initial dom-ready + webview.src = "about:blank" + container.appendChild(webview) + + return () => { + webview.removeEventListener("dom-ready", handleDomReady) + webview.removeEventListener("will-navigate", handleWillNavigate) + webview.removeEventListener("did-navigate", handleDidNavigate) + webview.removeEventListener("new-window", handleNewWindow) + webview.removeEventListener("console-message", handleConsoleMessage) + if (container.contains(webview)) { + container.removeChild(webview) + } + webviewRef.current = null + webviewReadyRef.current = false + pendingUrlRef.current = null + } + }, []) // Empty deps - create once + + // Navigate when URL changes (handles both ready and pending states) + useEffect(() => { + if (!state.selectedUrl) return + + const webview = webviewRef.current + if (!webview) return + + // If webview is ready, navigate immediately + if (webviewReadyRef.current) { + baseUrlRef.current = state.selectedUrl + webview.src = state.selectedUrl + } else { + // Otherwise queue it for when dom-ready fires + pendingUrlRef.current = state.selectedUrl + } + }, [state.selectedUrl]) + + // Resize handling for split position + const handleResizeStart = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + e.stopPropagation() + + const startY = e.clientY + const startPos = splitPosition + const pointerId = e.pointerId + const target = e.currentTarget as HTMLElement + + // Capture pointer to ensure we get all events + target.setPointerCapture(pointerId) + setIsSplitResizing(true) + + const onMove = (e: PointerEvent) => { + if (!panelContainerRef.current) return + const rect = panelContainerRef.current.getBoundingClientRect() + const delta = ((e.clientY - startY) / rect.height) * 100 + setSplitPosition(Math.min(Math.max(startPos + delta, 20), 80)) + } + + const onUp = () => { + target.releasePointerCapture(pointerId) + setIsSplitResizing(false) + document.removeEventListener("pointermove", onMove) + document.removeEventListener("pointerup", onUp) + document.removeEventListener("pointercancel", onUp) + } + + document.addEventListener("pointermove", onMove) + document.addEventListener("pointerup", onUp) + document.addEventListener("pointercancel", onUp) + }, + [splitPosition, setSplitPosition] + ) + + if (!worktreePath) return null + + return ( + setIsOpen(false)} + widthAtom={previewSidebarWidthAtom} + minWidth={400} + maxWidth={1200} + side="right" + animationDuration={0} + initialWidth={0} + exitWidth={0} + showResizeTooltip + className="bg-tl-background border-l" + style={{ borderLeftWidth: "0.5px", overflow: "hidden" }} + onResizeChange={setIsSidebarResizing} + > +
+ {/* Resize overlay - blocks webview from capturing events during resize */} + {isAnyResizing && ( +
+ )} + + {/* Header */} +
+ + + + + Close preview + + Preview +
+ + {/* Browser Panel */} +
+ {/* Toolbar */} +
+ {/* Navigation */} + + + + + Back + + + + + + + Forward + + + + + + + + {state.detectedUrls.map((u) => ( + handleSelectUrl(u.url)} + className="font-mono text-xs" + > + {u.url} + + ))} + + + + + + + + Refresh + + + + + + + Open in browser + + + + + + + Developer tools + + + + + + + + {isSelectorActive ? "Cancel element selection" : "Select element"} + + +
+ + {/* Preview - webview always mounted, visibility controlled */} +
+
+ {!state.selectedUrl && ( +
+ {isRunning ? "Waiting for dev server..." : "Start the dev server to preview"} +
+ )} +
+
+ + {/* Resize Handle */} +
+ + {/* Terminal Panel */} +
+
+
+ Output + + + + + Copy logs + +
+ + +
+ +
+ {state.output.length === 0 ? ( +
+ Click "Start" to run `bun run dev` +
+ ) : ( + window.desktopApi?.openExternal(url)} + /> + )} +
+
+
+ + ) +} diff --git a/src/renderer/features/preview-sidebar/terminal-output.tsx b/src/renderer/features/preview-sidebar/terminal-output.tsx new file mode 100644 index 00000000..522d1f49 --- /dev/null +++ b/src/renderer/features/preview-sidebar/terminal-output.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useMemo } from "react" +import type { Terminal as XTerm } from "xterm" +import type { FitAddon } from "@xterm/addon-fit" +import { useAtomValue } from "jotai" +import { useTheme } from "next-themes" +import { createTerminalInstance, getDefaultTerminalBg } from "../terminal/helpers" +import { getTerminalThemeFromVSCode } from "../terminal/config" +import { fullThemeDataAtom } from "@/lib/atoms" +import "xterm/css/xterm.css" + +interface TerminalOutputProps { + /** New data chunks to append (will be written to terminal) */ + data: string[] + /** Called when URL is clicked in terminal output */ + onUrlClick?: (url: string) => void +} + +/** + * Lightweight read-only terminal output viewer using xterm.js. + * Displays ANSI-colored output without input handling. + */ +export function TerminalOutput({ data, onUrlClick }: TerminalOutputProps) { + const containerRef = useRef(null) + const xtermRef = useRef(null) + const fitAddonRef = useRef(null) + const lastDataLengthRef = useRef(0) + + // Use ref for callback to avoid recreating xterm on every render + const onUrlClickRef = useRef(onUrlClick) + onUrlClickRef.current = onUrlClick + + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === "dark" + const fullThemeData = useAtomValue(fullThemeDataAtom) + + // Initialize xterm once + useEffect(() => { + const container = containerRef.current + if (!container) return + + const { xterm, fitAddon, cleanup } = createTerminalInstance(container, { + isDark, + onUrlClick: (url) => onUrlClickRef.current?.(url), + }) + + xtermRef.current = xterm + fitAddonRef.current = fitAddon + + // Resize observer for auto-fit + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + try { + fitAddon.fit() + } catch { + // Ignore fit errors during rapid resize + } + }) + }) + resizeObserver.observe(container) + + return () => { + resizeObserver.disconnect() + cleanup() + xtermRef.current = null + fitAddonRef.current = null + } + }, [isDark]) + + // Write new data incrementally + useEffect(() => { + const xterm = xtermRef.current + if (!xterm || data.length === 0) return + + // Only write new data since last update + const newData = data.slice(lastDataLengthRef.current) + if (newData.length > 0) { + for (const chunk of newData) { + xterm.write(chunk) + } + lastDataLengthRef.current = data.length + } + }, [data]) + + // Reset when data is cleared + useEffect(() => { + if (data.length === 0 && lastDataLengthRef.current > 0) { + xtermRef.current?.clear() + lastDataLengthRef.current = 0 + } + }, [data.length]) + + // Update theme dynamically + useEffect(() => { + if (xtermRef.current) { + const newTheme = getTerminalThemeFromVSCode(fullThemeData?.colors, isDark) + xtermRef.current.options.theme = newTheme + } + }, [isDark, fullThemeData]) + + const terminalBg = useMemo(() => { + if (fullThemeData?.colors?.["terminal.background"]) { + return fullThemeData.colors["terminal.background"] + } + if (fullThemeData?.colors?.["editor.background"]) { + return fullThemeData.colors["editor.background"] + } + return getDefaultTerminalBg(isDark) + }, [isDark, fullThemeData]) + + return ( +
+
+
+ ) +} diff --git a/src/renderer/features/preview-sidebar/types.ts b/src/renderer/features/preview-sidebar/types.ts new file mode 100644 index 00000000..39ce893b --- /dev/null +++ b/src/renderer/features/preview-sidebar/types.ts @@ -0,0 +1,14 @@ +export interface DetectedUrl { + url: string + port: number + host: string + timestamp: number +} + +export interface PreviewTerminalState { + isRunning: boolean + output: string[] + detectedUrls: DetectedUrl[] + selectedUrl: string | null + exitCode: number | null +} diff --git a/src/renderer/features/preview-sidebar/url-parser.ts b/src/renderer/features/preview-sidebar/url-parser.ts new file mode 100644 index 00000000..94344a05 --- /dev/null +++ b/src/renderer/features/preview-sidebar/url-parser.ts @@ -0,0 +1,69 @@ +import type { DetectedUrl } from "./types" + +// Regex to match localhost URLs with various formats +// Matches: http://localhost:3000, http://127.0.0.1:3000, http://0.0.0.0:3000 +// Also matches URLs with paths like http://localhost:3000/api +const LOCALHOST_URL_REGEX = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)(?:\/[^\s]*)?/gi + +// Ports to exclude from detection - these are used by the Electron app itself +// 5199: electron-vite renderer dev server (custom port in electron.vite.config.ts) +// 21322: Auth server +const EXCLUDED_PORTS = new Set([5199, 21322]) + +/** + * Parse terminal output for localhost URLs + * Optimized for performance - uses regex with minimal allocations + */ +export function parseUrlsFromOutput(output: string): DetectedUrl[] { + const matches = output.matchAll(LOCALHOST_URL_REGEX) + const urls: DetectedUrl[] = [] + const seenPorts = new Set() + const now = Date.now() + + for (const match of matches) { + const url = match[0] + const port = parseInt(match[1], 10) + + // Skip excluded ports (Electron app's own servers) + if (EXCLUDED_PORTS.has(port)) continue + + // Skip if we've already seen this port + if (seenPorts.has(port)) continue + seenPorts.add(port) + + // Normalize URL to use localhost + const normalizedUrl = url.replace(/127\.0\.0\.1|0\.0\.0\.0/, "localhost") + + urls.push({ + url: normalizedUrl, + port, + host: "localhost", + timestamp: now, + }) + } + + // Sort by port number for consistent ordering + return urls.sort((a, b) => a.port - b.port) +} + +/** + * Incrementally parse new output and merge with existing URLs + * Returns new URLs that weren't previously detected + */ +export function parseAndMergeUrls( + newOutput: string, + existingUrls: DetectedUrl[] +): { urls: DetectedUrl[]; hasNew: boolean } { + const newUrls = parseUrlsFromOutput(newOutput) + const existingPorts = new Set(existingUrls.map(u => u.port)) + + const addedUrls = newUrls.filter(u => !existingPorts.has(u.port)) + + if (addedUrls.length === 0) { + return { urls: existingUrls, hasNew: false } + } + + // Merge and sort + const merged = [...existingUrls, ...addedUrls].sort((a, b) => a.port - b.port) + return { urls: merged, hasNew: true } +} diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index 845868ee..d703f79a 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -25,6 +25,8 @@ import { showWorkspaceIconAtom, } from "../../lib/atoms" import { ArchivePopover } from "../agents/ui/archive-popover" +import { RunningServersSection } from "./running-servers-popover" +import { McpServersSection } from "./mcp-servers-popover" import { ChevronDown, MoreHorizontal } from "lucide-react" // import { useRouter } from "next/navigation" // Desktop doesn't use next/navigation // import { useCombinedAuth } from "@/lib/hooks/use-combined-auth" @@ -113,6 +115,7 @@ import { Checkbox } from "../../components/ui/checkbox" import { useHaptic } from "./hooks/use-haptic" import { TypewriterText } from "../../components/ui/typewriter-text" import { exportChat, copyChat, type ExportFormat } from "../agents/lib/export-chat" +import { runningDevServersAtom } from "../preview-sidebar" // Feedback URL: uses env variable for hosted version, falls back to public Discord for open source const FEEDBACK_URL = @@ -405,6 +408,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ hasUnseenChanges, hasPendingPlan, hasPendingQuestion, + hasRunningDevServer, isMultiSelectMode, isChecked, isFocused, @@ -451,6 +455,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ hasUnseenChanges: boolean hasPendingPlan: boolean hasPendingQuestion: boolean + hasRunningDevServer: boolean isMultiSelectMode: boolean isChecked: boolean isFocused: boolean @@ -638,6 +643,12 @@ const AgentChatItem = React.memo(function AgentChatItem({
{displayText}
+ {hasRunningDevServer && ( + + + + + )} {stats && (stats.additions > 0 || stats.deletions > 0) && ( <> @@ -803,6 +814,7 @@ interface ChatListSectionProps { unseenChanges: Set workspacePendingPlans: Set workspacePendingQuestions: Set + runningDevServers: Set isMultiSelectMode: boolean selectedChatIds: Set isMobileFullscreen: boolean @@ -844,6 +856,7 @@ const ChatListSection = React.memo(function ChatListSection({ unseenChanges, workspacePendingPlans, workspacePendingQuestions, + runningDevServers, isMultiSelectMode, selectedChatIds, isMobileFullscreen, @@ -913,6 +926,7 @@ const ChatListSection = React.memo(function ChatListSection({ const stats = workspaceFileStats.get(chat.id) const hasPendingPlan = workspacePendingPlans.has(chat.id) const hasPendingQuestion = workspacePendingQuestions.has(chat.id) + const hasRunningDevServer = runningDevServers.has(chat.id) const isLastInFilteredChats = globalIndex === filteredChats.length - 1 const isJustCreated = justCreatedIds.has(chat.id) @@ -930,6 +944,7 @@ const ChatListSection = React.memo(function ChatListSection({ hasUnseenChanges={unseenChanges.has(chat.id)} hasPendingPlan={hasPendingPlan} hasPendingQuestion={hasPendingQuestion} + hasRunningDevServer={hasRunningDevServer} isMultiSelectMode={isMultiSelectMode} isChecked={isChecked} isFocused={isFocused} @@ -1475,6 +1490,7 @@ export function AgentsSidebar({ // Read unseen changes from global atoms const unseenChanges = useAtomValue(agentsUnseenChangesAtom) const justCreatedIds = useAtomValue(justCreatedIdsAtom) + const runningDevServers = useAtomValue(runningDevServersAtom) // Haptic feedback const { trigger: triggerHaptic } = useHaptic() @@ -2642,6 +2658,7 @@ export function AgentsSidebar({ unseenChanges={unseenChanges} workspacePendingPlans={workspacePendingPlans} workspacePendingQuestions={workspacePendingQuestions} + runningDevServers={runningDevServers} isMultiSelectMode={isMultiSelectMode} selectedChatIds={selectedChatIds} isMobileFullscreen={isMobileFullscreen} @@ -2683,6 +2700,7 @@ export function AgentsSidebar({ unseenChanges={unseenChanges} workspacePendingPlans={workspacePendingPlans} workspacePendingQuestions={workspacePendingQuestions} + runningDevServers={runningDevServers} isMultiSelectMode={isMultiSelectMode} selectedChatIds={selectedChatIds} isMobileFullscreen={isMobileFullscreen} @@ -2808,6 +2826,12 @@ export function AgentsSidebar({ {/* Help Button - isolated component to prevent sidebar re-renders */} + {/* Running Servers Button - desktop only */} + {isDesktop && } + + {/* MCP Servers Button - desktop only */} + {isDesktop && } + {/* Archive Button - isolated component to prevent sidebar re-renders */}
diff --git a/src/renderer/features/sidebar/mcp-servers-popover.tsx b/src/renderer/features/sidebar/mcp-servers-popover.tsx new file mode 100644 index 00000000..fd81fba5 --- /dev/null +++ b/src/renderer/features/sidebar/mcp-servers-popover.tsx @@ -0,0 +1,406 @@ +"use client" + +import React, { memo, useState, useRef, useEffect, useCallback, useMemo } from "react" +import { useAtom } from "jotai" +import { trpc } from "../../lib/trpc" +import { mcpServersPopoverOpenAtom } from "../../lib/atoms" +import { OriginalMCPIcon } from "../../components/ui/icons" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../../components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip" +import { cn } from "../../lib/utils" +import { ChevronRight, Loader2, RefreshCw, ExternalLink } from "lucide-react" + +// Status type matching the backend +type MCPServerStatus = "connected" | "failed" | "pending" | "needs-auth" + +interface MCPServer { + name: string + status: MCPServerStatus + tools: string[] + needsAuth: boolean + config: Record +} + +interface MCPGroup { + groupName: string + projectPath: string | null + mcpServers: MCPServer[] +} + +// Get status indicator based on server status +function getStatusIndicator(status: MCPServerStatus) { + switch (status) { + case "connected": + return ( + + ) + case "failed": + return ( + + ) + case "needs-auth": + return ( + + ) + case "pending": + return ( + + ) + default: + return ( + + ) + } +} + +function getStatusText(status: MCPServerStatus): string { + switch (status) { + case "connected": + return "Connected" + case "failed": + return "Connection failed" + case "needs-auth": + return "Needs authentication" + case "pending": + return "Connecting..." + default: + return status + } +} + +// Individual server row +interface ServerRowProps { + server: MCPServer + isExpanded: boolean + onToggle: () => void +} + +const ServerRow = memo(function ServerRow({ + server, + isExpanded, + onToggle, +}: ServerRowProps) { + const hasTools = server.tools.length > 0 + + return ( +
+ + + {/* Tools list (expanded) */} + {isExpanded && hasTools && ( +
+ {server.tools.map((tool) => ( +
+ {tool} +
+ ))} +
+ )} +
+ ) +}) + +// Main popover component +interface McpServersPopoverProps { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export const McpServersPopover = memo(function McpServersPopover({ + children, + open, + onOpenChange, +}: McpServersPopoverProps) { + const [expandedServers, setExpandedServers] = useState>(new Set()) + + // Fetch all MCP config + const { data, isLoading, refetch, isRefetching } = + trpc.claude.getAllMcpConfig.useQuery(undefined, { + staleTime: 30 * 1000, // 30 seconds + refetchOnWindowFocus: false, + }) + + const groups = (data?.groups || []) as MCPGroup[] + + // Count total connected servers + const connectedCount = useMemo(() => { + return groups.reduce((count, group) => { + return ( + count + group.mcpServers.filter((s) => s.status === "connected").length + ) + }, 0) + }, [groups]) + + // Count total servers + const totalCount = useMemo(() => { + return groups.reduce((count, group) => count + group.mcpServers.length, 0) + }, [groups]) + + // Reset expanded servers when popover closes + useEffect(() => { + if (!open) { + setExpandedServers(new Set()) + } + }, [open]) + + const toggleServer = useCallback((serverKey: string) => { + setExpandedServers((prev) => { + const next = new Set(prev) + if (next.has(serverKey)) { + next.delete(serverKey) + } else { + next.add(serverKey) + } + return next + }) + }, []) + + const handleRefresh = useCallback(() => { + refetch() + }, [refetch]) + + return ( + + {children} + +
+ {/* Header */} +
+
+ + MCP Servers + + {isLoading || isRefetching ? ( + + ) : ( + + )} +
+ + {connectedCount}/{totalCount} connected + +
+ + {/* Server list */} +
+ {isLoading ? ( +
+ + Loading MCP servers... +
+ ) : totalCount === 0 ? ( +
+ No MCP servers configured +
+ ) : ( +
+ {groups.map((group) => ( +
+ {/* Group header - only show if multiple groups or has project path */} + {(groups.length > 1 || group.projectPath) && ( +
+ {group.groupName} +
+ )} + + {/* Servers in group */} + {group.mcpServers.map((server) => { + const serverKey = `${group.groupName}:${server.name}` + return ( + toggleServer(serverKey)} + /> + ) + })} +
+ ))} +
+ )} +
+ + {/* Footer with config hint */} +
+ Configure in{" "} + ~/.claude.json{" "} + or{" "} + .mcp.json +
+
+
+
+ ) +}) + +// Sidebar button section component +interface McpServersSectionProps { + isMobile?: boolean +} + +export const McpServersSection = memo(function McpServersSection({ + isMobile = false, +}: McpServersSectionProps) { + const [popoverOpen, setPopoverOpen] = useAtom(mcpServersPopoverOpenAtom) + const [blockTooltip, setBlockTooltip] = useState(false) + const prevPopoverOpen = useRef(false) + const buttonRef = useRef(null) + + // Fetch MCP config for badge count + const { data, isLoading } = trpc.claude.getAllMcpConfig.useQuery(undefined, { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + }) + + const groups = (data?.groups || []) as MCPGroup[] + + // Count connected servers + const connectedCount = useMemo(() => { + return groups.reduce((count, group) => { + return ( + count + group.mcpServers.filter((s) => s.status === "connected").length + ) + }, 0) + }, [groups]) + + // Count total servers + const totalCount = useMemo(() => { + return groups.reduce((count, group) => count + group.mcpServers.length, 0) + }, [groups]) + + // Handle tooltip blocking when popover closes + useEffect(() => { + if (prevPopoverOpen.current && !popoverOpen) { + buttonRef.current?.blur() + setBlockTooltip(true) + const timer = setTimeout(() => setBlockTooltip(false), 300) + prevPopoverOpen.current = popoverOpen + return () => clearTimeout(timer) + } + prevPopoverOpen.current = popoverOpen + }, [popoverOpen]) + + // Don't show if no MCP servers configured and not loading + if (!isLoading && totalCount === 0) { + return null + } + + return ( + + +
+ + + +
+
+ + {isLoading + ? "Loading MCP servers..." + : totalCount > 0 + ? `${connectedCount}/${totalCount} MCP server${totalCount > 1 ? "s" : ""} connected` + : "MCP Servers"} + +
+ ) +}) diff --git a/src/renderer/features/sidebar/running-servers-popover.tsx b/src/renderer/features/sidebar/running-servers-popover.tsx new file mode 100644 index 00000000..1d263142 --- /dev/null +++ b/src/renderer/features/sidebar/running-servers-popover.tsx @@ -0,0 +1,550 @@ +"use client" + +import React, { memo, useState, useRef, useEffect, useCallback, useMemo } from "react" +import { useAtom } from "jotai" +import { trpc } from "../../lib/trpc" +import { runningServersPopoverOpenAtom } from "../../lib/atoms" +import { ServerIcon } from "../../components/ui/icons" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../../components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip" +import { cn } from "../../lib/utils" +import { Square, ExternalLink, Search, X, Check } from "lucide-react" +import { toast } from "sonner" +import type { DetectedPort } from "../terminal/types" + +// Format port address for display +function formatAddress(address: string, port: number): string { + if (address === "*" || address === "0.0.0.0" || address === "::") { + return `localhost:${port}` + } + return `${address}:${port}` +} + +// Common dev server ports to prioritize (Vite, Next.js, etc.) +const DEV_SERVER_PRIORITY: Record = { + Vite: 1, + "Next.js": 2, + Astro: 3, + Nuxt: 4, + SvelteKit: 5, + Remix: 6, + "Create React App": 7, + "Webpack Dev Server": 8, + Storybook: 9, +} + +// Vite default port range +const VITE_PORTS = new Set([5173, 5174, 5175, 5176, 5177, 5178, 5179]) + +function getServerPriority(server: DetectedPort): number { + // Check by process name first + const namePriority = DEV_SERVER_PRIORITY[server.processName] + if (namePriority !== undefined) return namePriority + + // Check if it's a Vite port + if (VITE_PORTS.has(server.port)) return 1 + + // Next.js default port + if (server.port === 3000) return 2 + + // Other common dev ports + if (server.port >= 3000 && server.port <= 3999) return 10 + if (server.port >= 8000 && server.port <= 8999) return 11 + + return 100 // Default priority (lower priority) +} + +// Generate a unique key for a server (paneId:port) +function getServerKey(server: DetectedPort): string { + return `${server.paneId}:${server.port}` +} + +// Individual server row +interface ServerRowProps { + server: DetectedPort + serverKey: string + onStop: (pid: number, processName: string) => void + isKilling: boolean + isSelected: boolean + onToggleSelect: (key: string) => void + showCheckbox: boolean +} + +const ServerRow = memo(function ServerRow({ + server, + serverKey, + onStop, + isKilling, + isSelected, + onToggleSelect, + showCheckbox, +}: ServerRowProps) { + const address = formatAddress(server.address, server.port) + const url = `http://${address}` + + const handleOpenInBrowser = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + window.open(url, "_blank") + }, + [url] + ) + + const handleStop = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onStop(server.pid, server.processName) + }, + [onStop, server.pid, server.processName] + ) + + const handleToggle = useCallback(() => { + onToggleSelect(serverKey) + }, [onToggleSelect, serverKey]) + + return ( +
+ {/* Checkbox */} + {showCheckbox && ( + + )} + +
+
+ + :{server.port} + + + {server.processName} + +
+
+ +
+ {/* Open in browser */} + + + + + Open in browser + + + {/* Stop server */} + + + + + Stop server + +
+
+ ) +}) + +// Main popover component +interface RunningServersPopoverProps { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export const RunningServersPopover = memo(function RunningServersPopover({ + children, + open, + onOpenChange, +}: RunningServersPopoverProps) { + const [killingPids, setKillingPids] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState("") + const [selectedKeys, setSelectedKeys] = useState>(new Set()) + const searchInputRef = useRef(null) + + // Fetch all ports + const { data: ports = [], refetch } = trpc.terminal.getAllPorts.useQuery( + undefined, + { + refetchInterval: 2500, // Match the scan interval + } + ) + + // Subscribe to port changes for real-time updates + trpc.terminal.portChanges.useSubscription(undefined, { + onData: () => { + refetch() + }, + }) + + // Filter ports based on search query + // Sort and filter ports - prioritize dev servers like Vite + const filteredPorts = useMemo(() => { + let result = [...ports] + + // Filter by search query if present + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase() + result = result.filter( + (port) => + port.port.toString().includes(query) || + port.processName.toLowerCase().includes(query) + ) + } + + // Sort by priority (Vite first), then by most recently detected + result.sort((a, b) => { + const priorityA = getServerPriority(a) + const priorityB = getServerPriority(b) + if (priorityA !== priorityB) return priorityA - priorityB + return b.detectedAt - a.detectedAt // Most recent first within same priority + }) + + return result + }, [ports, searchQuery]) + + // Clear selection when ports change (in case a selected process was killed) + useEffect(() => { + const validKeys = new Set(ports.map((p) => getServerKey(p))) + setSelectedKeys((prev) => { + const next = new Set() + for (const key of prev) { + if (validKeys.has(key)) next.add(key) + } + return next.size === prev.size ? prev : next + }) + }, [ports]) + + // Reset search and selection when popover closes + useEffect(() => { + if (!open) { + setSearchQuery("") + setSelectedKeys(new Set()) + } + }, [open]) + + // Kill process mutation + const killProcess = trpc.terminal.killProcessByPid.useMutation({ + onSuccess: (_, variables) => { + setKillingPids((prev) => { + const next = new Set(prev) + next.delete(variables.pid) + return next + }) + // Clear selections for any ports that belonged to this PID + setSelectedKeys((prev) => { + const keysToRemove = ports + .filter((p) => p.pid === variables.pid) + .map((p) => getServerKey(p)) + if (keysToRemove.length === 0) return prev + const next = new Set(prev) + for (const key of keysToRemove) { + next.delete(key) + } + return next + }) + }, + onError: (error, variables) => { + setKillingPids((prev) => { + const next = new Set(prev) + next.delete(variables.pid) + return next + }) + toast.error(`Failed to stop server: ${error.message}`) + }, + }) + + const handleStop = useCallback( + (pid: number, _processName: string) => { + setKillingPids((prev) => new Set(prev).add(pid)) + killProcess.mutate({ pid }) + }, + [killProcess] + ) + + const handleToggleSelect = useCallback((key: string) => { + setSelectedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + }, []) + + const handleStopSelected = useCallback(async () => { + // Map selected keys back to unique PIDs (multiple ports can share a PID) + const pidsToKill = new Set() + for (const key of selectedKeys) { + const server = ports.find((p) => getServerKey(p) === key) + if (server) pidsToKill.add(server.pid) + } + const pidArray = Array.from(pidsToKill) + for (const pid of pidArray) { + setKillingPids((prev) => new Set(prev).add(pid)) + killProcess.mutate({ pid }) + } + toast.success(`Stopping ${selectedKeys.size} server${selectedKeys.size > 1 ? "s" : ""}`) + }, [selectedKeys, ports, killProcess]) + + const handleSelectAll = useCallback(() => { + const allKeys = new Set(filteredPorts.map((p) => getServerKey(p))) + setSelectedKeys((prev) => { + // If all filtered are selected, deselect all + const allSelected = filteredPorts.every((p) => prev.has(getServerKey(p))) + if (allSelected) { + const next = new Set(prev) + for (const p of filteredPorts) { + next.delete(getServerKey(p)) + } + return next + } + // Otherwise, select all filtered + return new Set([...prev, ...allKeys]) + }) + }, [filteredPorts]) + + const hasSelection = selectedKeys.size > 0 + const allFilteredSelected = + filteredPorts.length > 0 && filteredPorts.every((p) => selectedKeys.has(getServerKey(p))) + + return ( + + {children} + +
+ {/* Header */} +
+ + Running Servers + + + {ports.length} active + +
+ + {/* Search bar */} + {ports.length > 0 && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="Filter by port or name..." + className="w-full h-7 pl-7 pr-7 text-sm bg-muted/50 border border-border rounded-md placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {searchQuery && ( + + )} +
+
+ )} + + {/* Selection actions bar */} + {ports.length > 0 && ( +
+ + {hasSelection && ( + + )} +
+ )} + + {/* Server list */} +
+ {ports.length === 0 ? ( +
+ No servers running +
+ ) : filteredPorts.length === 0 ? ( +
+ No matches found +
+ ) : ( +
+ {filteredPorts.map((server) => { + const serverKey = getServerKey(server) + return ( + 1} + /> + ) + })} +
+ )} +
+
+
+
+ ) +}) + +// Sidebar button section component +interface RunningServersSectionProps { + isMobile?: boolean +} + +export const RunningServersSection = memo(function RunningServersSection({ + isMobile = false, +}: RunningServersSectionProps) { + const [popoverOpen, setPopoverOpen] = useAtom(runningServersPopoverOpenAtom) + const [blockTooltip, setBlockTooltip] = useState(false) + const prevPopoverOpen = useRef(false) + const buttonRef = useRef(null) + + // Fetch port count for badge + const { data: ports = [] } = trpc.terminal.getAllPorts.useQuery(undefined, { + refetchInterval: 2500, + }) + + // Subscribe to port changes + trpc.terminal.portChanges.useSubscription(undefined, { + onData: () => { + // Query will auto-refetch + }, + }) + + // Handle tooltip blocking when popover closes + useEffect(() => { + if (prevPopoverOpen.current && !popoverOpen) { + buttonRef.current?.blur() + setBlockTooltip(true) + const timer = setTimeout(() => setBlockTooltip(false), 300) + prevPopoverOpen.current = popoverOpen + return () => clearTimeout(timer) + } + prevPopoverOpen.current = popoverOpen + }, [popoverOpen]) + + const portCount = ports.length + + return ( + + +
+ + + +
+
+ + {portCount > 0 + ? `${portCount} server${portCount > 1 ? "s" : ""} running` + : "Running Servers"} + +
+ ) +}) diff --git a/src/renderer/features/terminal/types.ts b/src/renderer/features/terminal/types.ts index e1af1478..48862d77 100644 --- a/src/renderer/features/terminal/types.ts +++ b/src/renderer/features/terminal/types.ts @@ -1,3 +1,8 @@ +export interface TerminalStartedEvent { + type: "started" + cwd: string +} + export interface TerminalDataEvent { type: "data" data: string @@ -9,7 +14,7 @@ export interface TerminalExitEvent { signal?: number } -export type TerminalEvent = TerminalDataEvent | TerminalExitEvent +export type TerminalEvent = TerminalStartedEvent | TerminalDataEvent | TerminalExitEvent export interface TerminalProps { paneId: string @@ -21,7 +26,8 @@ export interface TerminalProps { } export interface TerminalStreamEvent { - type: "data" | "exit" + type: "started" | "data" | "exit" + cwd?: string data?: string exitCode?: number signal?: number @@ -41,3 +47,16 @@ export interface TerminalInstance { /** Creation timestamp */ createdAt: number } + +/** + * Represents a detected localhost server/port running in a terminal session + */ +export interface DetectedPort { + port: number + pid: number + processName: string + paneId: string + workspaceId: string + detectedAt: number + address: string +} diff --git a/src/renderer/index.html b/src/renderer/index.html index c816b405..4b0cc46a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,7 +3,7 @@ - + 1Code