Merge pull request #16 from d-kimuson/refactor/effect-ts
refactor: Introduce Effect-TS architecture and improve code organization
22
.vscode/settings.json
vendored
@@ -7,7 +7,27 @@
|
||||
"biome.enabled": true,
|
||||
// autofix
|
||||
"editor.formatOnSave": false,
|
||||
"[typescript][typescriptreact][javascript][javascriptreact][json][jsonc][json][yaml]": {
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TaskExecutor } from "../utils/TaskExecutor";
|
||||
import { errorPagesCapture } from "./error-pages";
|
||||
import { homeCapture } from "./home";
|
||||
import { projectDetailCapture } from "./project-detail";
|
||||
import { projectsCapture } from "./projects";
|
||||
import { sessionDetailCapture } from "./session-detail";
|
||||
|
||||
@@ -17,7 +16,6 @@ const tasks = [
|
||||
...homeCapture.tasks,
|
||||
...errorPagesCapture.tasks,
|
||||
...projectsCapture.tasks,
|
||||
...projectDetailCapture.tasks,
|
||||
...sessionDetailCapture.tasks,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { projectIds } from "../config";
|
||||
import { defineCapture } from "../utils/defineCapture";
|
||||
|
||||
export const projectDetailCapture = defineCapture({
|
||||
href: `projects/${projectIds.sampleProject}`,
|
||||
cases: [
|
||||
{
|
||||
name: "filters-expanded",
|
||||
setup: async (page) => {
|
||||
const filterToggle = page.locator(
|
||||
'[data-testid="expand-filter-settings-button"]',
|
||||
);
|
||||
if (await filterToggle.isVisible()) {
|
||||
await filterToggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
} else {
|
||||
throw new Error("Filter settings button not found");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new-chat-modal",
|
||||
setup: async (page) => {
|
||||
const newChatButton = page.locator('[data-testid="new-chat"]');
|
||||
if (await newChatButton.isVisible()) {
|
||||
await newChatButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { encodeProjectId } from "../src/server/service/project/id";
|
||||
import { encodeProjectId } from "../src/server/core/project/functions/id";
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: env var
|
||||
export const globalClaudeDirectoryPath = process.env["GLOBAL_CLAUDE_DIR"]
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 55 KiB |
@@ -39,6 +39,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.98",
|
||||
"@effect/platform": "^0.92.1",
|
||||
"@effect/platform-node": "^0.98.3",
|
||||
"@hono/zod-validator": "^0.7.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -48,9 +50,12 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"effect": "^3.18.4",
|
||||
"es-toolkit": "^1.40.0",
|
||||
"hono": "^4.9.5",
|
||||
"jotai": "^2.13.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
499
pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
||||
'@anthropic-ai/claude-code':
|
||||
specifier: ^1.0.98
|
||||
version: 1.0.128
|
||||
'@effect/platform':
|
||||
specifier: ^0.92.1
|
||||
version: 0.92.1(effect@3.18.4)
|
||||
'@effect/platform-node':
|
||||
specifier: ^0.98.3
|
||||
version: 0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@hono/zod-validator':
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(hono@4.9.5)(zod@4.1.5)
|
||||
@@ -38,6 +44,9 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.85.5
|
||||
version: 5.85.5(react@19.1.1)
|
||||
@@ -47,6 +56,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
effect:
|
||||
specifier: ^3.18.4
|
||||
version: 3.18.4
|
||||
es-toolkit:
|
||||
specifier: ^1.40.0
|
||||
version: 1.40.0
|
||||
hono:
|
||||
specifier: ^4.9.5
|
||||
version: 4.9.5
|
||||
@@ -225,6 +240,71 @@ packages:
|
||||
conventional-commits-parser:
|
||||
optional: true
|
||||
|
||||
'@effect/cluster@0.50.4':
|
||||
resolution: {integrity: sha512-9uS2pRN4BCguAGqFCLFlQkReXG993UFj/TLtiwaXsacytKhdlGBU5zDDI/TckbM0wUv4g2nZPRRywqU8qnrvjQ==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
'@effect/workflow': ^0.11.3
|
||||
effect: ^3.18.4
|
||||
|
||||
'@effect/experimental@0.56.0':
|
||||
resolution: {integrity: sha512-ZT9wTUVyDptzdkW4Tfvz5fNzygW9vt5jWcFmKI9SlhZMu9unVJgsBhxWCNYCyfPnxw3n/Z6SEKsqgt8iKQc4MA==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
ioredis: ^5
|
||||
lmdb: ^3
|
||||
peerDependenciesMeta:
|
||||
ioredis:
|
||||
optional: true
|
||||
lmdb:
|
||||
optional: true
|
||||
|
||||
'@effect/platform-node-shared@0.51.4':
|
||||
resolution: {integrity: sha512-xElU9+cNPa1BnUHAZ3sVVanuuKof8oWQhK7rbyHNqgWM7CZTjv7x9VMDs0X05+1OcTQnnW3E+SrZKIPCfcYlDQ==}
|
||||
peerDependencies:
|
||||
'@effect/cluster': ^0.50.3
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
effect: ^3.18.2
|
||||
|
||||
'@effect/platform-node@0.98.3':
|
||||
resolution: {integrity: sha512-90eMWmFSVHrUEreICCd2qLPiw7qcaAv9XTx9OJ+LLv7igQgt4qkisRSK0oxAr5hqU9TdUrsgFDohqe7q7h3ZRg==}
|
||||
peerDependencies:
|
||||
'@effect/cluster': ^0.50.3
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
effect: ^3.18.1
|
||||
|
||||
'@effect/platform@0.92.1':
|
||||
resolution: {integrity: sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg==}
|
||||
peerDependencies:
|
||||
effect: ^3.18.1
|
||||
|
||||
'@effect/rpc@0.71.0':
|
||||
resolution: {integrity: sha512-m6mFX0ShdA+fnYAyamz7SRKF4FepaDB/ZhBri6iue26tBF2LrOFJUWewbwv8/LdLSedkO4eukhsHXuEYortL/w==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
|
||||
'@effect/sql@0.46.0':
|
||||
resolution: {integrity: sha512-nm9TuTTG7gLmJlIPkf71wA5lXArSvkpm1oYoIF+rhf01wef+1ujz9Mv1SfuzYbzsk7W9+OXUIRMxz/nSlKkiGQ==}
|
||||
peerDependencies:
|
||||
'@effect/experimental': ^0.56.0
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
|
||||
'@effect/workflow@0.11.3':
|
||||
resolution: {integrity: sha512-3uyj0yOc2QRtQVOw6NEJVEMOhN/F7khhnf3QSU+2T3wvuDag9iBUzJFvSls8PgNCO3j/GgeaWzbcXwxqpFQYOQ==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
effect: ^3.18.1
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||
|
||||
@@ -741,6 +821,36 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@next/env@15.5.2':
|
||||
resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==}
|
||||
|
||||
@@ -853,6 +963,88 @@ packages:
|
||||
'@octokit/types@14.1.0':
|
||||
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@phun-ky/typeof@1.2.8':
|
||||
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
|
||||
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
|
||||
@@ -1130,6 +1322,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -1342,6 +1547,9 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -1862,6 +2070,11 @@ packages:
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
detect-libc@2.0.4:
|
||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1884,6 +2097,9 @@ packages:
|
||||
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
effect@3.18.4:
|
||||
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
|
||||
|
||||
emoji-regex@10.5.0:
|
||||
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
|
||||
|
||||
@@ -1897,6 +2113,9 @@ packages:
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-toolkit@1.40.0:
|
||||
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
|
||||
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1956,6 +2175,10 @@ packages:
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
fast-check@3.23.2:
|
||||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
fast-content-type-parse@2.0.1:
|
||||
resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==}
|
||||
|
||||
@@ -1979,6 +2202,9 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-my-way-ts@0.1.6:
|
||||
resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==}
|
||||
|
||||
format@0.2.2:
|
||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
@@ -2498,6 +2724,10 @@ packages:
|
||||
micromark@4.0.2:
|
||||
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2506,6 +2736,11 @@ packages:
|
||||
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@4.0.0:
|
||||
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2554,6 +2789,16 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
msgpackr-extract@3.0.3:
|
||||
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
|
||||
hasBin: true
|
||||
|
||||
msgpackr@1.11.5:
|
||||
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||
|
||||
multipasta@0.2.7:
|
||||
resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==}
|
||||
|
||||
mute-stream@2.0.0:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
@@ -2598,9 +2843,16 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
node-gyp-build-optional-packages@5.2.2:
|
||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2785,6 +3037,9 @@ packages:
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -3132,6 +3387,10 @@ packages:
|
||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicorn-magic@0.3.0:
|
||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3186,6 +3445,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -3291,6 +3554,18 @@ packages:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3393,6 +3668,75 @@ snapshots:
|
||||
conventional-commits-filter: 5.0.0
|
||||
conventional-commits-parser: 6.2.0
|
||||
|
||||
'@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/workflow': 0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
|
||||
'@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
uuid: 11.1.0
|
||||
|
||||
'@effect/platform-node-shared@0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@parcel/watcher': 2.5.1
|
||||
effect: 3.18.4
|
||||
multipasta: 0.2.7
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@effect/platform-node@0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/platform-node-shared': 0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
mime: 3.0.0
|
||||
undici: 7.16.0
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@effect/platform@0.92.1(effect@3.18.4)':
|
||||
dependencies:
|
||||
effect: 3.18.4
|
||||
find-my-way-ts: 0.1.6
|
||||
msgpackr: 1.11.5
|
||||
multipasta: 0.2.7
|
||||
|
||||
'@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
msgpackr: 1.11.5
|
||||
|
||||
'@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/experimental': 0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
uuid: 11.1.0
|
||||
|
||||
'@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3779,6 +4123,24 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@next/env@15.5.2': {}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.2':
|
||||
@@ -3877,6 +4239,66 @@ snapshots:
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 25.1.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
dependencies:
|
||||
detect-libc: 1.0.3
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
node-addon-api: 7.1.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-x64': 2.5.1
|
||||
'@parcel/watcher-freebsd-x64': 2.5.1
|
||||
'@parcel/watcher-linux-arm-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm-musl': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-musl': 2.5.1
|
||||
'@parcel/watcher-linux-x64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-x64-musl': 2.5.1
|
||||
'@parcel/watcher-win32-arm64': 2.5.1
|
||||
'@parcel/watcher-win32-ia32': 2.5.1
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
|
||||
'@phun-ky/typeof@1.2.8': {}
|
||||
|
||||
'@playwright/test@1.55.0':
|
||||
@@ -4164,6 +4586,26 @@ snapshots:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
@@ -4309,6 +4751,8 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -4831,6 +5275,8 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@1.0.3: {}
|
||||
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
@@ -4847,6 +5293,11 @@ snapshots:
|
||||
|
||||
dotenv@17.2.1: {}
|
||||
|
||||
effect@3.18.4:
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
fast-check: 3.23.2
|
||||
|
||||
emoji-regex@10.5.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -4858,6 +5309,8 @@ snapshots:
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-toolkit@1.40.0: {}
|
||||
|
||||
esbuild@0.25.9:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
@@ -4946,6 +5399,10 @@ snapshots:
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-check@3.23.2:
|
||||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
fast-content-type-parse@2.0.1: {}
|
||||
|
||||
fault@1.0.4:
|
||||
@@ -4964,6 +5421,8 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-my-way-ts@0.1.6: {}
|
||||
|
||||
format@0.2.2: {}
|
||||
|
||||
fs-minipass@2.1.0:
|
||||
@@ -5642,12 +6101,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@3.0.1:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
mimic-fn@4.0.0: {}
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
@@ -5684,6 +6150,24 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msgpackr-extract@3.0.3:
|
||||
dependencies:
|
||||
node-gyp-build-optional-packages: 5.2.2
|
||||
optionalDependencies:
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
|
||||
optional: true
|
||||
|
||||
msgpackr@1.11.5:
|
||||
optionalDependencies:
|
||||
msgpackr-extract: 3.0.3
|
||||
|
||||
multipasta@0.2.7: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@@ -5723,8 +6207,15 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-gyp-build-optional-packages@5.2.2:
|
||||
dependencies:
|
||||
detect-libc: 2.0.4
|
||||
optional: true
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
npm-normalize-package-bin@4.0.0: {}
|
||||
@@ -5945,6 +6436,8 @@ snapshots:
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -6370,6 +6863,8 @@ snapshots:
|
||||
|
||||
undici@6.21.3: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -6428,6 +6923,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -6542,6 +7039,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
@@ -6,6 +6,10 @@ if [ -d "dist/.next" ]; then
|
||||
rm -rf dist/.next
|
||||
fi
|
||||
|
||||
if [ -d "dist/standalone" ]; then
|
||||
rm -rf dist/standalone
|
||||
fi
|
||||
|
||||
pnpm exec next build
|
||||
cp -r public .next/standalone/
|
||||
cp -r .next/static .next/standalone/.next/
|
||||
|
||||
@@ -1,8 +1,79 @@
|
||||
import { NodeContext } from "@effect/platform-node";
|
||||
import { Effect } from "effect";
|
||||
import { handle } from "hono/vercel";
|
||||
import { ClaudeCodeController } from "../../../server/core/claude-code/presentation/ClaudeCodeController";
|
||||
import { ClaudeCodePermissionController } from "../../../server/core/claude-code/presentation/ClaudeCodePermissionController";
|
||||
import { ClaudeCodeSessionProcessController } from "../../../server/core/claude-code/presentation/ClaudeCodeSessionProcessController";
|
||||
import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService";
|
||||
import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService";
|
||||
import { ClaudeCodeService } from "../../../server/core/claude-code/services/ClaudeCodeService";
|
||||
import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService";
|
||||
import { SSEController } from "../../../server/core/events/presentation/SSEController";
|
||||
import { EventBus } from "../../../server/core/events/services/EventBus";
|
||||
import { FileWatcherService } from "../../../server/core/events/services/fileWatcher";
|
||||
import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController";
|
||||
import { GitController } from "../../../server/core/git/presentation/GitController";
|
||||
import { GitService } from "../../../server/core/git/services/GitService";
|
||||
import { ApplicationContext } from "../../../server/core/platform/services/ApplicationContext";
|
||||
import { EnvService } from "../../../server/core/platform/services/EnvService";
|
||||
import { UserConfigService } from "../../../server/core/platform/services/UserConfigService";
|
||||
import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository";
|
||||
import { ProjectController } from "../../../server/core/project/presentation/ProjectController";
|
||||
import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService";
|
||||
import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase";
|
||||
import { SessionController } from "../../../server/core/session/presentation/SessionController";
|
||||
import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService";
|
||||
import { honoApp } from "../../../server/hono/app";
|
||||
import { InitializeService } from "../../../server/hono/initialize";
|
||||
import { routes } from "../../../server/hono/route";
|
||||
|
||||
routes(honoApp);
|
||||
const program = routes(honoApp);
|
||||
|
||||
await Effect.runPromise(
|
||||
program
|
||||
// 依存の浅い順にコンテナに pipe する必要がある
|
||||
.pipe(
|
||||
/** Presentation */
|
||||
Effect.provide(ProjectController.Live),
|
||||
Effect.provide(SessionController.Live),
|
||||
Effect.provide(GitController.Live),
|
||||
Effect.provide(ClaudeCodeController.Live),
|
||||
Effect.provide(ClaudeCodeSessionProcessController.Live),
|
||||
Effect.provide(ClaudeCodePermissionController.Live),
|
||||
Effect.provide(FileSystemController.Live),
|
||||
Effect.provide(SSEController.Live),
|
||||
)
|
||||
.pipe(
|
||||
/** Application */
|
||||
Effect.provide(InitializeService.Live),
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
)
|
||||
.pipe(
|
||||
/** Domain */
|
||||
Effect.provide(ClaudeCodeLifeCycleService.Live),
|
||||
Effect.provide(ClaudeCodePermissionService.Live),
|
||||
Effect.provide(ClaudeCodeSessionProcessService.Live),
|
||||
Effect.provide(ClaudeCodeService.Live),
|
||||
Effect.provide(GitService.Live),
|
||||
)
|
||||
.pipe(
|
||||
/** Infrastructure */
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(VirtualConversationDatabase.Live),
|
||||
)
|
||||
.pipe(
|
||||
/** Platform */
|
||||
Effect.provide(ApplicationContext.Live),
|
||||
Effect.provide(UserConfigService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
Effect.provide(EnvService.Live),
|
||||
Effect.provide(NodeContext.layer),
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = handle(honoApp);
|
||||
export const POST = handle(honoApp);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { FC } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
@@ -15,6 +19,9 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
content,
|
||||
className = "",
|
||||
}) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-neutral dark:prose-invert max-w-none ${className}`}
|
||||
@@ -136,7 +143,7 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="!mt-0 !rounded-t-none !rounded-b-lg !border-t-0 !border !border-border"
|
||||
@@ -168,6 +175,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 underline underline-offset-4 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Home, RefreshCw } from "lucide-react";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export const RootErrorBoundary: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={({ error }) => (
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
<p>{error.message}</p>
|
||||
FallbackComponent={({ error, resetErrorBoundary }) => (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
<CardDescription>
|
||||
An unexpected error occurred in the application
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="text-xs">{error.message}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={resetErrorBoundary} variant="default">
|
||||
<RefreshCw />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Home />
|
||||
Go to Home
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,33 +5,26 @@ import { useSetAtom } from "jotai";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries";
|
||||
import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener";
|
||||
import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
|
||||
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
|
||||
|
||||
export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const setAliveTasks = useSetAtom(aliveTasksAtom);
|
||||
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
|
||||
|
||||
useServerEventListener("sessionListChanged", async (event) => {
|
||||
// invalidate session list
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: projectDetailQuery(event.projectId).queryKey,
|
||||
});
|
||||
});
|
||||
|
||||
useServerEventListener("sessionChanged", async (event) => {
|
||||
// invalidate session detail
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey,
|
||||
});
|
||||
});
|
||||
|
||||
useServerEventListener("taskChanged", async ({ aliveTasks, changed }) => {
|
||||
setAliveTasks(aliveTasks);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionDetailQuery(changed.projectId, changed.sessionId)
|
||||
.queryKey,
|
||||
});
|
||||
useServerEventListener("sessionProcessChanged", async ({ processes }) => {
|
||||
setSessionProcesses(processes);
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
18
src/app/components/SyncSessionProcess.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { type FC, type PropsWithChildren, useEffect } from "react";
|
||||
import type { PublicSessionProcess } from "../../types/session-process";
|
||||
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
|
||||
|
||||
export const SyncSessionProcess: FC<
|
||||
PropsWithChildren<{ initProcesses: PublicSessionProcess[] }>
|
||||
> = ({ children, initProcesses }) => {
|
||||
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionProcesses(initProcesses);
|
||||
}, [initProcesses, setSessionProcesses]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
69
src/app/error.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Home, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
<CardDescription>
|
||||
An unexpected error occurred in the application
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="text-xs">{error.message}</code>
|
||||
{error.digest && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="default">
|
||||
<RefreshCw />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Home />
|
||||
Go to Home
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { useCallback } from "react";
|
||||
import { honoClient } from "../../lib/api/client";
|
||||
import { configQuery } from "../../lib/api/queries";
|
||||
import type { Config } from "../../server/config/config";
|
||||
import type { UserConfig } from "../../server/lib/config/config";
|
||||
|
||||
export const useConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,7 +16,7 @@ export const useConfig = () => {
|
||||
queryFn: configQuery.queryFn,
|
||||
});
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async (config: Config) => {
|
||||
mutationFn: async (config: UserConfig) => {
|
||||
const response = await honoClient.api.config.$put({
|
||||
json: config,
|
||||
});
|
||||
@@ -32,7 +32,7 @@ export const useConfig = () => {
|
||||
return {
|
||||
config: data?.config,
|
||||
updateConfig: useCallback(
|
||||
(config: Config) => {
|
||||
(config: UserConfig) => {
|
||||
updateConfigMutation.mutate(config);
|
||||
},
|
||||
[updateConfigMutation],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||
@@ -7,8 +8,10 @@ import { SSEProvider } from "../lib/sse/components/SSEProvider";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
|
||||
import "./globals.css";
|
||||
import { honoClient } from "../lib/api/client";
|
||||
import { configQuery } from "../lib/api/queries";
|
||||
import { SSEEventListeners } from "./components/SSEEventListeners";
|
||||
import { SyncSessionProcess } from "./components/SyncSessionProcess";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
@@ -40,19 +43,31 @@ export default async function RootLayout({
|
||||
queryFn: configQuery.queryFn,
|
||||
});
|
||||
|
||||
const initSessionProcesses = await honoClient.api.cc["session-processes"]
|
||||
.$get({})
|
||||
.then((response) => response.json());
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>
|
||||
<SSEProvider>
|
||||
<SSEEventListeners>{children}</SSEEventListeners>
|
||||
</SSEProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>
|
||||
<SSEProvider>
|
||||
<SSEEventListeners>
|
||||
<SyncSessionProcess
|
||||
initProcesses={initSessionProcesses.processes}
|
||||
>
|
||||
{children}
|
||||
</SyncSessionProcess>
|
||||
</SSEEventListeners>
|
||||
</SSEProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
41
src/app/not-found.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FileQuestion, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileQuestion className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Page Not Found</CardTitle>
|
||||
<CardDescription>
|
||||
The page you are looking for does not exist
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="default">
|
||||
<Link href="/">
|
||||
<Home />
|
||||
Go to Home
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDownIcon,
|
||||
FolderIcon,
|
||||
MessageSquareIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsControls } from "@/components/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { projectDetailQuery } from "../../../../lib/api/queries";
|
||||
import { useConfig } from "../../../hooks/useConfig";
|
||||
import { useProject } from "../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../services/firstCommandToTitle";
|
||||
import { NewChatModal } from "./newChat/NewChatModal";
|
||||
|
||||
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
const {
|
||||
data: { project, sessions },
|
||||
} = useProject(projectId);
|
||||
const { config } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
|
||||
useEffect(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: projectDetailQuery(projectId).queryKey,
|
||||
});
|
||||
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8 max-w-6xl">
|
||||
<header className="mb-6 sm:mb-8">
|
||||
<Button asChild variant="ghost" className="mb-4">
|
||||
<Link href="/projects" className="flex items-center gap-2">
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Back to Projects</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<FolderIcon className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold break-words overflow-hidden">
|
||||
{project.meta.projectPath ?? project.claudeProjectPath}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<NewChatModal
|
||||
projectId={projectId}
|
||||
trigger={
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
data-testid="new-chat"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">Start New Chat</span>
|
||||
<span className="sm:hidden">New Chat</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs sm:text-sm break-all">
|
||||
History File: {project.claudeProjectPath ?? "unknown"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2 className="text-lg sm:text-xl font-semibold mb-4">
|
||||
Conversation Sessions{" "}
|
||||
{sessions.length > 0 ? `(${sessions.length})` : ""}
|
||||
</h2>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<Collapsible open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
|
||||
<div className="mb-6">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between mb-2 h-auto py-3"
|
||||
data-testid="expand-filter-settings-button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
<span className="font-medium">Filter Settings</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({sessions.length} sessions)
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
isSettingsOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||
<SettingsControls openingProjectId={projectId} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No sessions found</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
No conversation sessions found for this project. Start a
|
||||
conversation with Claude Code in this project to create
|
||||
sessions.
|
||||
</p>
|
||||
<NewChatModal
|
||||
projectId={projectId}
|
||||
trigger={
|
||||
<Button size="lg" className="gap-2">
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
Start First Chat
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="break-words overflow-ellipsis line-clamp-2 text-lg sm:text-xl">
|
||||
{session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
: session.id}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{session.id}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{session.meta.messageCount} messages
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last modified:{" "}
|
||||
{session.meta.lastModifiedAt
|
||||
? new Date(
|
||||
session.meta.lastModifiedAt,
|
||||
).toLocaleDateString()
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{session.jsonlFilePath}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
href={`/projects/${projectId}/sessions/${encodeURIComponent(
|
||||
session.id,
|
||||
)}`}
|
||||
>
|
||||
View Session
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
LoaderIcon,
|
||||
SendIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useCallback, useId, useRef, useState } from "react";
|
||||
import { Button } from "../../../../../components/ui/button";
|
||||
import { Textarea } from "../../../../../components/ui/textarea";
|
||||
@@ -62,16 +67,28 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
|
||||
// IMEで変換中の場合は送信しない
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
const enterKeyBehavior = config?.enterKeyBehavior;
|
||||
|
||||
if (isEnterSend && !e.shiftKey) {
|
||||
if (enterKeyBehavior === "enter-send" && !e.shiftKey && !e.metaKey) {
|
||||
// Enter: Send mode
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (!isEnterSend && e.shiftKey) {
|
||||
} else if (
|
||||
enterKeyBehavior === "shift-enter-send" &&
|
||||
e.shiftKey &&
|
||||
!e.metaKey
|
||||
) {
|
||||
// Shift+Enter: Send mode (default)
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (
|
||||
enterKeyBehavior === "command-enter-send" &&
|
||||
e.metaKey &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
// Command+Enter: Send mode (Mac)
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -148,78 +165,98 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md mb-4">
|
||||
<AlertCircleIcon className="w-4 h-4" />
|
||||
<span>Failed to send message. Please try again.</span>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-gradient-to-r from-red-50 to-red-100/50 dark:from-red-950/30 dark:to-red-900/20 border border-red-200/50 dark:border-red-800/50 rounded-xl mb-4 animate-in fade-in slide-in-from-top-2 duration-300 shadow-sm">
|
||||
<AlertCircleIcon className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="font-medium">
|
||||
Failed to send message. Please try again.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value.endsWith("@") ||
|
||||
e.target.value.endsWith("/")
|
||||
) {
|
||||
const position = getCursorPosition();
|
||||
if (position) {
|
||||
setCursorPosition(position);
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="absolute -inset-0.5 bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-pink-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative bg-background border border-border/40 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value.endsWith("@") ||
|
||||
e.target.value.endsWith("/")
|
||||
) {
|
||||
const position = getCursorPosition();
|
||||
if (position) {
|
||||
setCursorPosition(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={`${minHeight} resize-none`}
|
||||
disabled={isPending || disabled}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with completion support"
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/") || message.includes("@")}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<InlineCompletion
|
||||
projectId={projectId}
|
||||
message={message}
|
||||
commandCompletionRef={commandCompletionRef}
|
||||
fileCompletionRef={fileCompletionRef}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handleFileSelect={handleFileSelect}
|
||||
cursorPosition={cursorPosition}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground" id={helpId}>
|
||||
{message.length}/4000 characters " • Use arrow keys to navigate
|
||||
completions"
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim() || isPending || disabled}
|
||||
size={buttonSize}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
Sending... This may take a while.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
{buttonText}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={`${minHeight} resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent px-5 py-4 text-lg transition-all duration-200 placeholder:text-muted-foreground/60`}
|
||||
disabled={isPending || disabled}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with completion support"
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/") || message.includes("@")}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 bg-muted/30 border-t border-border/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-medium text-muted-foreground/80"
|
||||
id={helpId}
|
||||
>
|
||||
{message.length}
|
||||
<span className="text-muted-foreground/50">/4000</span>
|
||||
</span>
|
||||
{(message.startsWith("/") || message.includes("@")) && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium flex items-center gap-1">
|
||||
<SparklesIcon className="w-3 h-3" />
|
||||
Autocomplete active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim() || isPending || disabled}
|
||||
size={buttonSize}
|
||||
className="gap-2 transition-all duration-200 hover:shadow-md hover:scale-105 active:scale-95 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 disabled:from-muted disabled:to-muted"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
{buttonText}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<InlineCompletion
|
||||
projectId={projectId}
|
||||
message={message}
|
||||
commandCompletionRef={commandCompletionRef}
|
||||
fileCompletionRef={fileCompletionRef}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handleFileSelect={handleFileSelect}
|
||||
cursorPosition={cursorPosition}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -185,49 +185,53 @@ export const CommandCompletion = forwardRef<
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{ height: "15rem" }}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
>
|
||||
{filteredCommands.length > 0 && (
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
|
||||
role="presentation"
|
||||
>
|
||||
<TerminalIcon className="w-3 h-3" />
|
||||
Available Commands ({filteredCommands.length})
|
||||
</div>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<Button
|
||||
key={command}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
|
||||
index === selectedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleCommandSelect(command)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`Command: /${command}`}
|
||||
title={`/${command}`}
|
||||
<div className="h-full overflow-y-auto">
|
||||
{filteredCommands.length > 0 && (
|
||||
<div className="p-1.5">
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
|
||||
role="presentation"
|
||||
>
|
||||
<span className="text-muted-foreground mr-1 flex-shrink-0">
|
||||
/
|
||||
</span>
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{command}
|
||||
</span>
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TerminalIcon className="w-3.5 h-3.5" />
|
||||
Available Commands ({filteredCommands.length})
|
||||
</div>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<Button
|
||||
key={command}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
|
||||
index === selectedIndex
|
||||
? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => handleCommandSelect(command)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`Command: /${command}`}
|
||||
title={`/${command}`}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1.5 flex-shrink-0">
|
||||
/
|
||||
</span>
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{command}
|
||||
</span>
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -259,63 +259,67 @@ export const FileCompletion = forwardRef<
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{ height: "15rem" }}
|
||||
role="listbox"
|
||||
aria-label="Available files and directories"
|
||||
>
|
||||
{filteredEntries.length > 0 && (
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
|
||||
role="presentation"
|
||||
>
|
||||
<FileIcon className="w-3 h-3" />
|
||||
Files & Directories ({filteredEntries.length})
|
||||
{basePath !== "/" && (
|
||||
<span className="text-xs font-mono text-muted-foreground/70">
|
||||
in {basePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
|
||||
index === selectedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleEntrySelect(entry, entry.type === "file")
|
||||
}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`${entry.type}: ${entry.name}`}
|
||||
title={entry.path}
|
||||
<div className="h-full overflow-y-auto">
|
||||
{filteredEntries.length > 0 && (
|
||||
<div className="p-1.5">
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
|
||||
role="presentation"
|
||||
>
|
||||
{entry.type === "directory" ? (
|
||||
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.type === "directory" && (
|
||||
<span className="text-muted-foreground ml-1 flex-shrink-0">
|
||||
/
|
||||
<FileIcon className="w-3.5 h-3.5" />
|
||||
Files & Directories ({filteredEntries.length})
|
||||
{basePath !== "/" && (
|
||||
<span className="text-xs font-mono text-muted-foreground/70">
|
||||
in {basePath}
|
||||
</span>
|
||||
)}
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
|
||||
index === selectedIndex
|
||||
? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleEntrySelect(entry, entry.type === "file")
|
||||
}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`${entry.type}: ${entry.name}`}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.type === "directory" ? (
|
||||
<FolderIcon className="w-3.5 h-3.5 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5 mr-2 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.type === "directory" && (
|
||||
<span className="text-muted-foreground ml-1 flex-shrink-0">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -15,13 +15,21 @@ interface PositionStyle {
|
||||
const calculateOptimalPosition = (
|
||||
relativeCursorPosition: { top: number; left: number },
|
||||
absoluteCursorPosition: { top: number; left: number },
|
||||
itemCount: number,
|
||||
): PositionStyle => {
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 800;
|
||||
const viewportCenter = viewportHeight / 2;
|
||||
|
||||
// Estimated completion height (we'll measure actual height later if needed)
|
||||
const estimatedCompletionHeight = 200;
|
||||
// Calculate dynamic height based on item count
|
||||
// Header: ~48px, Each item: 36px (h-9), Padding: 12px
|
||||
const headerHeight = 48;
|
||||
const itemHeight = 36;
|
||||
const padding = 12;
|
||||
const maxItems = 5;
|
||||
const visibleItems = Math.min(itemCount, maxItems);
|
||||
const estimatedCompletionHeight =
|
||||
headerHeight + itemHeight * visibleItems + padding;
|
||||
|
||||
// Determine preferred placement based on viewport position
|
||||
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
|
||||
@@ -33,22 +41,22 @@ const calculateOptimalPosition = (
|
||||
let placement: "above" | "below";
|
||||
let top: number;
|
||||
|
||||
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) {
|
||||
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight + 20) {
|
||||
// Cursor in upper half and enough space below - place below
|
||||
placement = "below";
|
||||
top = relativeCursorPosition.top + 16;
|
||||
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) {
|
||||
top = relativeCursorPosition.top + 24;
|
||||
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight + 20) {
|
||||
// Cursor in lower half and enough space above - place above
|
||||
placement = "above";
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
|
||||
} else {
|
||||
// Use whichever side has more space
|
||||
if (spaceBelow > spaceAbove) {
|
||||
placement = "below";
|
||||
top = relativeCursorPosition.top + 16;
|
||||
top = relativeCursorPosition.top + 24;
|
||||
} else {
|
||||
placement = "above";
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +101,7 @@ export const InlineCompletion: FC<{
|
||||
return calculateOptimalPosition(
|
||||
cursorPosition.relative,
|
||||
cursorPosition.absolute,
|
||||
5,
|
||||
);
|
||||
}, [cursorPosition]);
|
||||
|
||||
|
||||
@@ -4,4 +4,7 @@ export type { CommandCompletionRef } from "./CommandCompletion";
|
||||
export { CommandCompletion } from "./CommandCompletion";
|
||||
export type { FileCompletionRef } from "./FileCompletion";
|
||||
export { FileCompletion } from "./FileCompletion";
|
||||
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";
|
||||
export {
|
||||
useContinueSessionProcessMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "./useChatMutations";
|
||||
|
||||
@@ -2,20 +2,24 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { honoClient } from "../../../../../lib/api/client";
|
||||
|
||||
export const useNewChatMutation = (
|
||||
export const useCreateSessionProcessMutation = (
|
||||
projectId: string,
|
||||
onSuccess?: () => void,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"new-session"
|
||||
].$post(
|
||||
mutationFn: async (options: {
|
||||
message: string;
|
||||
baseSessionId?: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.cc["session-processes"].$post(
|
||||
{
|
||||
param: { projectId },
|
||||
json: { message: options.message },
|
||||
json: {
|
||||
projectId,
|
||||
baseSessionId: options.baseSessionId,
|
||||
message: options.message,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: {
|
||||
@@ -33,27 +37,31 @@ export const useNewChatMutation = (
|
||||
onSuccess: async (response) => {
|
||||
onSuccess?.();
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}` +
|
||||
response.userMessageId !==
|
||||
undefined
|
||||
? `#message-${response.userMessageId}`
|
||||
: "",
|
||||
`/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
|
||||
const router = useRouter();
|
||||
|
||||
export const useContinueSessionProcessMutation = (
|
||||
projectId: string,
|
||||
baseSessionId: string,
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||
":sessionId"
|
||||
].resume.$post(
|
||||
mutationFn: async (options: {
|
||||
message: string;
|
||||
sessionProcessId: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.cc["session-processes"][
|
||||
":sessionProcessId"
|
||||
].continue.$post(
|
||||
{
|
||||
param: { projectId, sessionId },
|
||||
json: { resumeMessage: options.message },
|
||||
param: { sessionProcessId: options.sessionProcessId },
|
||||
json: {
|
||||
projectId: projectId,
|
||||
baseSessionId: baseSessionId,
|
||||
continueMessage: options.message,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: {
|
||||
@@ -68,12 +76,5 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => {
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
if (sessionId !== response.sessionId) {
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../hooks/useConfig";
|
||||
import { ChatInput, useNewChatMutation } from "../chatForm";
|
||||
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
|
||||
|
||||
export const NewChat: FC<{
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
}> = ({ projectId, onSuccess }) => {
|
||||
const startNewChat = useNewChatMutation(projectId, onSuccess);
|
||||
const createSessionProcess = useCreateSessionProcessMutation(
|
||||
projectId,
|
||||
onSuccess,
|
||||
);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await startNewChat.mutateAsync({ message });
|
||||
await createSessionProcess.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
if (isEnterSend) {
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
|
||||
}
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message here... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
}
|
||||
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
@@ -25,12 +31,13 @@ export const NewChat: FC<{
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={startNewChat.isPending}
|
||||
error={startNewChat.error}
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText="Start Chat"
|
||||
minHeight="min-h-[200px]"
|
||||
containerClassName="space-y-4"
|
||||
containerClassName="p-6"
|
||||
buttonSize="lg"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
67
src/app/projects/[projectId]/error.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function ProjectErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<div>
|
||||
<CardTitle>Failed to load project</CardTitle>
|
||||
<CardDescription>
|
||||
We encountered an error while loading this project
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="text-xs">{error.message}</code>
|
||||
{error.digest && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="default">
|
||||
<RefreshCw />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/projects")} variant="outline">
|
||||
<ArrowLeft />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
|
||||
import { projectDetailQuery } from "../../../../lib/api/queries";
|
||||
|
||||
export const useProject = (projectId: string) => {
|
||||
return useSuspenseQuery({
|
||||
return useSuspenseInfiniteQuery({
|
||||
queryKey: projectDetailQuery(projectId).queryKey,
|
||||
queryFn: projectDetailQuery(projectId).queryFn,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const result = await projectDetailQuery(projectId, pageParam).queryFn();
|
||||
return result;
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
};
|
||||
|
||||
25
src/app/projects/[projectId]/latest/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { redirect } from "next/navigation";
|
||||
import { latestSessionQuery } from "../../../../lib/api/queries";
|
||||
|
||||
interface LatestSessionPageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export default async function LatestSessionPage({
|
||||
params,
|
||||
}: LatestSessionPageProps) {
|
||||
const { projectId } = await params;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const { latestSession } = await queryClient.fetchQuery(
|
||||
latestSessionQuery(projectId),
|
||||
);
|
||||
|
||||
if (!latestSession) {
|
||||
redirect(`/projects`);
|
||||
}
|
||||
|
||||
redirect(`/projects/${projectId}/sessions/${latestSession.id}`);
|
||||
}
|
||||
42
src/app/projects/[projectId]/not-found.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FolderSearch, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function ProjectNotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderSearch className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Project Not Found</CardTitle>
|
||||
<CardDescription>
|
||||
The project you are looking for does not exist or has been
|
||||
removed
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="default">
|
||||
<Link href="/projects">
|
||||
<Home />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { projectDetailQuery } from "../../../lib/api/queries";
|
||||
import { ProjectPageContent } from "./components/ProjectPage";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface ProjectPageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
@@ -12,17 +6,5 @@ interface ProjectPageProps {
|
||||
|
||||
export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
const { projectId } = await params;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: projectDetailQuery(projectId).queryKey,
|
||||
queryFn: projectDetailQuery(projectId).queryFn,
|
||||
});
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ProjectPageContent projectId={projectId} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
redirect(`/projects/${projectId}/latest`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedCommand } from "../../../../server/service/parseCommandXml";
|
||||
import type { ParsedUserMessage } from "../../../../server/core/claude-code/functions/parseUserMessage";
|
||||
|
||||
export const firstCommandToTitle = (firstCommand: ParsedCommand) => {
|
||||
export const firstUserMessageToTitle = (firstCommand: ParsedUserMessage) => {
|
||||
switch (firstCommand.kind) {
|
||||
case "command":
|
||||
if (firstCommand.commandArgs === undefined) {
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GitCompareIcon,
|
||||
LoaderIcon,
|
||||
MenuIcon,
|
||||
PauseIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PermissionDialog } from "@/components/PermissionDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
|
||||
@@ -19,11 +17,12 @@ import { useTaskNotifications } from "@/hooks/useTaskNotifications";
|
||||
import { Badge } from "../../../../../../components/ui/badge";
|
||||
import { honoClient } from "../../../../../../lib/api/client";
|
||||
import { useProject } from "../../../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useAliveTask } from "../hooks/useAliveTask";
|
||||
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useSessionProcess } from "../hooks/useSessionProcess";
|
||||
import { ConversationList } from "./conversationList/ConversationList";
|
||||
import { DiffModal } from "./diffModal";
|
||||
import { ContinueChat } from "./resumeChat/ContinueChat";
|
||||
import { ResumeChat } from "./resumeChat/ResumeChat";
|
||||
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
|
||||
|
||||
@@ -35,12 +34,17 @@ export const SessionPageContent: FC<{
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
const { data: project } = useProject(projectId);
|
||||
const { data: projectData } = useProject(projectId);
|
||||
// biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page
|
||||
const project = projectData.pages[0]!.project;
|
||||
|
||||
const abortTask = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await honoClient.api.tasks.abort.$post({
|
||||
json: { sessionId },
|
||||
mutationFn: async (sessionProcessId: string) => {
|
||||
const response = await honoClient.api.cc["session-processes"][
|
||||
":sessionProcessId"
|
||||
].abort.$post({
|
||||
param: { sessionProcessId },
|
||||
json: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -50,13 +54,18 @@ export const SessionPageContent: FC<{
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
const sessionProcess = useSessionProcess();
|
||||
|
||||
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
|
||||
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
|
||||
usePermissionRequests();
|
||||
|
||||
const relatedSessionProcess = useMemo(
|
||||
() => sessionProcess.getSessionProcess(sessionId),
|
||||
[sessionProcess, sessionId],
|
||||
);
|
||||
|
||||
// Set up task completion notifications
|
||||
useTaskNotifications(isRunningTask);
|
||||
useTaskNotifications(relatedSessionProcess?.status === "running");
|
||||
|
||||
const [previousConversationLength, setPreviousConversationLength] =
|
||||
useState(0);
|
||||
@@ -67,7 +76,7 @@ export const SessionPageContent: FC<{
|
||||
// 自動スクロール処理
|
||||
useEffect(() => {
|
||||
if (
|
||||
(isRunningTask || isPausedTask) &&
|
||||
relatedSessionProcess?.status === "running" &&
|
||||
conversations.length !== previousConversationLength
|
||||
) {
|
||||
setPreviousConversationLength(conversations.length);
|
||||
@@ -79,10 +88,14 @@ export const SessionPageContent: FC<{
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
|
||||
}, [
|
||||
conversations,
|
||||
relatedSessionProcess?.status,
|
||||
previousConversationLength,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">
|
||||
<>
|
||||
<SessionSidebar
|
||||
currentSessionId={sessionId}
|
||||
projectId={projectId}
|
||||
@@ -104,28 +117,20 @@ export const SessionPageContent: FC<{
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
|
||||
{session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
{session.meta.firstUserMessage !== null
|
||||
? firstUserMessageToTitle(session.meta.firstUserMessage)
|
||||
: sessionId}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
{project?.project.claudeProjectPath && (
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
target="_blank"
|
||||
className="transition-all duration-200"
|
||||
{project?.claudeProjectPath && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
||||
{project.project.meta.projectPath ??
|
||||
project.project.claudeProjectPath}
|
||||
</Badge>
|
||||
</Link>
|
||||
{project.meta.projectPath ?? project.claudeProjectPath}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -135,32 +140,43 @@ export const SessionPageContent: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isRunningTask && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
{relatedSessionProcess?.status === "running" && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs sm:text-sm font-medium">
|
||||
Conversation is in progress...
|
||||
</p>
|
||||
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full animate-pulse"
|
||||
style={{ width: "70%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
abortTask.mutate(sessionId);
|
||||
abortTask.mutate(relatedSessionProcess.id);
|
||||
}}
|
||||
disabled={abortTask.isPending}
|
||||
>
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
{abortTask.isPending ? (
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPausedTask && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
{relatedSessionProcess?.status === "paused" && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs sm:text-sm font-medium">
|
||||
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
|
||||
Conversation is paused...
|
||||
</p>
|
||||
</div>
|
||||
@@ -168,10 +184,16 @@ export const SessionPageContent: FC<{
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
abortTask.mutate(sessionId);
|
||||
abortTask.mutate(relatedSessionProcess.id);
|
||||
}}
|
||||
disabled={abortTask.isPending}
|
||||
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
|
||||
>
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
{abortTask.isPending ? (
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -189,29 +211,29 @@ export const SessionPageContent: FC<{
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
|
||||
{isRunningTask && (
|
||||
<div className="flex justify-start items-center py-8">
|
||||
{relatedSessionProcess?.status === "running" && (
|
||||
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
<p className="text-sm text-muted-foreground font-medium animate-pulse">
|
||||
Claude Code is processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResumeChat
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
isPausedTask={isPausedTask}
|
||||
isRunningTask={isRunningTask}
|
||||
/>
|
||||
{relatedSessionProcess !== undefined ? (
|
||||
<ContinueChat
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
sessionProcessId={relatedSessionProcess.id}
|
||||
/>
|
||||
) : (
|
||||
<ResumeChat projectId={projectId} sessionId={sessionId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,6 +260,6 @@ export const SessionPageContent: FC<{
|
||||
isOpen={isDialogOpen}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Lightbulb, Settings } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import type { FC } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -24,6 +30,8 @@ export const AssistantConversationContent: FC<{
|
||||
content: AssistantMessageContent;
|
||||
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
|
||||
}> = ({ content, getToolResult }) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
|
||||
if (content.type === "text") {
|
||||
return (
|
||||
<div className="w-full mx-1 sm:mx-2 my-4 sm:my-6">
|
||||
@@ -34,14 +42,16 @@ export const AssistantConversationContent: FC<{
|
||||
|
||||
if (content.type === "thinking") {
|
||||
return (
|
||||
<Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2">
|
||||
<Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2 hover:shadow-sm transition-all duration-200">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-colors py-0 px-4">
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-all duration-200 py-0 px-4 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">Thinking</CardTitle>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground group-hover:text-yellow-600 transition-colors" />
|
||||
<CardTitle className="text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
Thinking
|
||||
</CardTitle>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
@@ -80,16 +90,16 @@ export const AssistantConversationContent: FC<{
|
||||
<CardContent className="space-y-2 py-0 px-4">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
|
||||
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
|
||||
Input Parameters
|
||||
</h4>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SyntaxHighlighter
|
||||
style={oneLight}
|
||||
style={syntaxTheme}
|
||||
language="json"
|
||||
PreTag="div"
|
||||
className="text-xs"
|
||||
@@ -101,11 +111,11 @@ export const AssistantConversationContent: FC<{
|
||||
{toolResult && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
|
||||
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
|
||||
Tool Result
|
||||
</h4>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { Conversation } from "@/lib/conversation-schema";
|
||||
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
|
||||
import type { ErrorJsonl } from "../../../../../../../server/service/types";
|
||||
import type { ErrorJsonl } from "../../../../../../../server/core/types";
|
||||
import { useSidechain } from "../../hooks/useSidechain";
|
||||
import { ConversationItem } from "./ConversationItem";
|
||||
|
||||
@@ -143,7 +143,7 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
conversation.type === "summary"
|
||||
? "justify-start"
|
||||
: "justify-end"
|
||||
}`}
|
||||
} animate-in fade-in slide-in-from-bottom-2 duration-300`}
|
||||
key={getConversationKey(conversation)}
|
||||
>
|
||||
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]">
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { FC } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { parseCommandXml } from "../../../../../../../server/service/parseCommandXml";
|
||||
import { parseUserMessage } from "../../../../../../../server/core/claude-code/functions/parseUserMessage";
|
||||
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
|
||||
|
||||
export const UserTextContent: FC<{ text: string; id?: string }> = ({
|
||||
text,
|
||||
id,
|
||||
}) => {
|
||||
const parsed = parseCommandXml(text);
|
||||
const parsed = parseUserMessage(text);
|
||||
|
||||
if (parsed.kind === "command") {
|
||||
return (
|
||||
@@ -88,7 +88,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50"
|
||||
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50 dark:bg-slate-900/50"
|
||||
content={parsed.content}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
ChatInput,
|
||||
useContinueSessionProcessMutation,
|
||||
} from "../../../../components/chatForm";
|
||||
|
||||
export const ContinueChat: FC<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
sessionProcessId: string;
|
||||
}> = ({ projectId, sessionId, sessionProcessId }) => {
|
||||
const continueSessionProcess = useContinueSessionProcessMutation(
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await continueSessionProcess.mutateAsync({ message, sessionProcessId });
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
|
||||
}
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
}
|
||||
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
|
||||
<div className="pt-8">
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={continueSessionProcess.isPending}
|
||||
error={continueSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={"Send"}
|
||||
minHeight="min-h-[120px]"
|
||||
containerClassName=""
|
||||
buttonSize="lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,50 +2,50 @@ import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
ChatInput,
|
||||
useResumeChatMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "../../../../components/chatForm";
|
||||
|
||||
export const ResumeChat: FC<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
isPausedTask: boolean;
|
||||
isRunningTask: boolean;
|
||||
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
|
||||
const resumeChat = useResumeChatMutation(projectId, sessionId);
|
||||
}> = ({ projectId, sessionId }) => {
|
||||
const createSessionProcess = useCreateSessionProcessMutation(projectId);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await resumeChat.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isPausedTask || isRunningTask) {
|
||||
return "Send";
|
||||
}
|
||||
return "Resume";
|
||||
await createSessionProcess.mutateAsync({
|
||||
message,
|
||||
baseSessionId: sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
if (isEnterSend) {
|
||||
return "Type your message... (Start with / for commands, Enter to send)";
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
|
||||
}
|
||||
return "Type your message... (Start with / for commands, Shift+Enter to send)";
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
}
|
||||
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={resumeChat.isPending}
|
||||
error={resumeChat.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={getButtonText()}
|
||||
minHeight="min-h-[100px]"
|
||||
containerClassName="space-y-2"
|
||||
buttonSize="default"
|
||||
/>
|
||||
<div className="relative mt-8 mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
|
||||
<div className="pt-8">
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={"Resume"}
|
||||
minHeight="min-h-[120px]"
|
||||
containerClassName=""
|
||||
buttonSize="lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { mcpListQuery } from "../../../../../../../lib/api/queries";
|
||||
|
||||
export const McpTab: FC = () => {
|
||||
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
@@ -14,12 +14,14 @@ export const McpTab: FC = () => {
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: mcpListQuery.queryKey,
|
||||
queryFn: mcpListQuery.queryFn,
|
||||
queryKey: mcpListQuery(projectId).queryKey,
|
||||
queryFn: mcpListQuery(projectId).queryFn,
|
||||
});
|
||||
|
||||
const handleReload = () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpListQuery(projectId).queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MessageSquareIcon,
|
||||
PlugIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, Suspense, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { SettingsControls } from "@/components/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
import { McpTab } from "./McpTab";
|
||||
import { SessionsTab } from "./SessionsTab";
|
||||
import { SettingsTab } from "./SettingsTab";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
currentSessionId: string;
|
||||
@@ -24,8 +38,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const {
|
||||
data: { sessions },
|
||||
data: projectData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useProject(projectId);
|
||||
const sessions = projectData.pages.flatMap((page) => page.sessions);
|
||||
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
|
||||
"sessions",
|
||||
);
|
||||
@@ -71,15 +89,49 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
case "sessions":
|
||||
return (
|
||||
<SessionsTab
|
||||
sessions={sessions}
|
||||
sessions={sessions.map((session) => ({
|
||||
...session,
|
||||
lastModifiedAt: new Date(session.lastModifiedAt),
|
||||
}))}
|
||||
currentSessionId={currentSessionId}
|
||||
projectId={projectId}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
return <McpTab projectId={projectId} />;
|
||||
case "settings":
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-sm text-sidebar-foreground/70">
|
||||
Loading settings...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Session Display
|
||||
</h3>
|
||||
<SettingsControls openingProjectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
</h3>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -118,52 +170,89 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
>
|
||||
{/* Tab Icons */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("sessions")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "sessions"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="sessions-tab-button-mobile"
|
||||
>
|
||||
<MessageSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="w-12 h-12 flex items-center justify-center border-b border-sidebar-border hover:bg-sidebar-accent transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("mcp")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "mcp"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="mcp-tab-button-mobile"
|
||||
>
|
||||
<PlugIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("sessions")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "sessions"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="sessions-tab-button-mobile"
|
||||
>
|
||||
<MessageSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>セッション一覧を表示</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("settings")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "settings"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="settings-tab-button-mobile"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("mcp")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "mcp"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="mcp-tab-button-mobile"
|
||||
>
|
||||
<PlugIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>MCPサーバー設定を表示</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("settings")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "settings"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid="settings-tab-button-mobile"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>表示と通知の設定</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, useMemo } from "react";
|
||||
import type { SidebarTab } from "@/components/GlobalSidebar";
|
||||
import { GlobalSidebar } from "@/components/GlobalSidebar";
|
||||
import {
|
||||
MessageSquareIcon,
|
||||
PlugIcon,
|
||||
SettingsIcon,
|
||||
Undo2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useState } from "react";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
import { McpTab } from "./McpTab";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { SessionsTab } from "./SessionsTab";
|
||||
import { SettingsTab } from "./SettingsTab";
|
||||
|
||||
export const SessionSidebar: FC<{
|
||||
currentSessionId: string;
|
||||
@@ -28,134 +30,77 @@ export const SessionSidebar: FC<{
|
||||
isMobileOpen = false,
|
||||
onMobileOpenChange,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: { sessions },
|
||||
data: projectData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useProject(projectId);
|
||||
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
|
||||
"sessions",
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const sessions = projectData.pages.flatMap((page) => page.sessions);
|
||||
|
||||
const handleTabClick = (tab: "sessions" | "mcp" | "settings") => {
|
||||
if (activeTab === tab && isExpanded) {
|
||||
// If clicking the active tab while expanded, collapse
|
||||
setIsExpanded(false);
|
||||
} else {
|
||||
// If clicking a different tab or expanding, show that tab
|
||||
setActiveTab(tab);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case "sessions":
|
||||
return (
|
||||
const additionalTabs: SidebarTab[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "sessions",
|
||||
icon: MessageSquareIcon,
|
||||
title: "セッション一覧を表示",
|
||||
content: (
|
||||
<SessionsTab
|
||||
sessions={sessions}
|
||||
sessions={sessions.map((session) => ({
|
||||
...session,
|
||||
lastModifiedAt: new Date(session.lastModifiedAt),
|
||||
}))}
|
||||
currentSessionId={currentSessionId}
|
||||
projectId={projectId}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
case "settings":
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out flex bg-sidebar text-sidebar-foreground",
|
||||
isExpanded ? "w-72 lg:w-80" : "w-12",
|
||||
)}
|
||||
>
|
||||
{/* Vertical Icon Menu - Always Visible */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
router.push(`/projects/${projectId}`);
|
||||
}}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"text-sidebar-foreground/70",
|
||||
)}
|
||||
title="Back to Project"
|
||||
>
|
||||
<Undo2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("sessions")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "sessions" && isExpanded
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
title="Sessions"
|
||||
data-testid="sessions-tab-button"
|
||||
>
|
||||
<MessageSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("mcp")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "mcp" && isExpanded
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
title="MCP Servers"
|
||||
data-testid="mcp-tab-button"
|
||||
>
|
||||
<PlugIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("settings")}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === "settings" && isExpanded
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
title="Settings"
|
||||
data-testid="settings-tab-button"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Only shown when expanded */}
|
||||
{isExpanded && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{renderContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
icon: PlugIcon,
|
||||
title: "MCPサーバー設定を表示",
|
||||
content: <McpTab projectId={projectId} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
sessions,
|
||||
currentSessionId,
|
||||
projectId,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<div className={cn("hidden md:flex h-full", className)}>
|
||||
{sidebarContent}
|
||||
<GlobalSidebar
|
||||
projectId={projectId}
|
||||
additionalTabs={additionalTabs}
|
||||
defaultActiveTab="sessions"
|
||||
headerButton={
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
|
||||
@@ -7,28 +7,42 @@ import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Session } from "../../../../../../../server/service/types";
|
||||
import type { Session } from "../../../../../../../server/core/types";
|
||||
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
|
||||
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { aliveTasksAtom } from "../../store/aliveTasksAtom";
|
||||
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
|
||||
|
||||
export const SessionsTab: FC<{
|
||||
sessions: Session[];
|
||||
currentSessionId: string;
|
||||
projectId: string;
|
||||
}> = ({ sessions, currentSessionId, projectId }) => {
|
||||
const aliveTasks = useAtomValue(aliveTasksAtom);
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}> = ({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
projectId,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
const sessionProcesses = useAtomValue(sessionProcessesAtom);
|
||||
|
||||
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const aTask = aliveTasks.find((task) => task.sessionId === a.id);
|
||||
const bTask = aliveTasks.find((task) => task.sessionId === b.id);
|
||||
const aProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === a.id,
|
||||
);
|
||||
const bProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === b.id,
|
||||
);
|
||||
|
||||
const aStatus = aTask?.status;
|
||||
const bStatus = bTask?.status;
|
||||
const aStatus = aProcess?.status;
|
||||
const bStatus = bProcess?.status;
|
||||
|
||||
// Define priority: running = 0, paused = 1, others = 2
|
||||
const getPriority = (status: string | undefined) => {
|
||||
const getPriority = (status: "paused" | "running" | undefined) => {
|
||||
if (status === "running") return 0;
|
||||
if (status === "paused") return 1;
|
||||
return 2;
|
||||
@@ -43,12 +57,8 @@ export const SessionsTab: FC<{
|
||||
}
|
||||
|
||||
// Then sort by lastModifiedAt (newest first)
|
||||
const aTime = a.meta.lastModifiedAt
|
||||
? new Date(a.meta.lastModifiedAt).getTime()
|
||||
: 0;
|
||||
const bTime = b.meta.lastModifiedAt
|
||||
? new Date(b.meta.lastModifiedAt).getTime()
|
||||
: 0;
|
||||
const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0;
|
||||
const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
@@ -76,26 +86,24 @@ export const SessionsTab: FC<{
|
||||
{sortedSessions.map((session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
const title =
|
||||
session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
session.meta.firstUserMessage !== null
|
||||
? firstUserMessageToTitle(session.meta.firstUserMessage)
|
||||
: session.id;
|
||||
|
||||
const aliveTask = aliveTasks.find(
|
||||
const sessionProcess = sessionProcesses.find(
|
||||
(task) => task.sessionId === session.id,
|
||||
);
|
||||
const isRunning = aliveTask?.status === "running";
|
||||
const isPaused = aliveTask?.status === "paused";
|
||||
const isRunning = sessionProcess?.status === "running";
|
||||
const isPaused = sessionProcess?.status === "paused";
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/projects/${projectId}/sessions/${encodeURIComponent(
|
||||
session.id,
|
||||
)}`}
|
||||
href={`/projects/${projectId}/sessions/${session.id}`}
|
||||
className={cn(
|
||||
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
|
||||
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 dark:hover:bg-blue-950/40 hover:border-blue-300/60 dark:hover:border-blue-700/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
|
||||
isActive &&
|
||||
"bg-blue-100 border-blue-400 shadow-md ring-1 ring-blue-200/50 hover:bg-blue-100 hover:border-blue-400",
|
||||
"bg-blue-100 dark:bg-blue-900/50 border-blue-400 dark:border-blue-600 shadow-md ring-1 ring-blue-200/50 dark:ring-blue-700/50 hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:border-blue-400 dark:hover:border-blue-600",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
@@ -121,9 +129,9 @@ export const SessionsTab: FC<{
|
||||
<MessageSquareIcon className="w-3 h-3" />
|
||||
<span>{session.meta.messageCount}</span>
|
||||
</div>
|
||||
{session.meta.lastModifiedAt && (
|
||||
{session.lastModifiedAt && (
|
||||
<span className="text-xs text-sidebar-foreground/60">
|
||||
{new Date(session.meta.lastModifiedAt).toLocaleDateString(
|
||||
{new Date(session.lastModifiedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
@@ -137,6 +145,21 @@ export const SessionsTab: FC<{
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasNextPage && onLoadMore && (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { SettingsControls } from "@/components/SettingsControls";
|
||||
|
||||
export const SettingsTab: FC<{
|
||||
openingProjectId: string;
|
||||
}> = ({ openingProjectId }) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
<h2 className="font-semibold text-lg">Settings</h2>
|
||||
<p className="text-xs text-sidebar-foreground/70">
|
||||
Display and behavior preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Session Display Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Session Display
|
||||
</h3>
|
||||
|
||||
<SettingsControls openingProjectId={openingProjectId} />
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
</h3>
|
||||
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
src/app/projects/[projectId]/sessions/[sessionId]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function SessionErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<div>
|
||||
<CardTitle>Failed to load session</CardTitle>
|
||||
<CardDescription>
|
||||
We encountered an error while loading this conversation session
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="text-xs">{error.message}</code>
|
||||
{error.digest && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="default">
|
||||
<RefreshCw />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/projects/${projectId}`)}
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back to Project
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { aliveTasksQuery } from "../../../../../../lib/api/queries";
|
||||
import { aliveTasksAtom } from "../store/aliveTasksAtom";
|
||||
|
||||
export const useAliveTask = (sessionId: string) => {
|
||||
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
|
||||
|
||||
useQuery({
|
||||
queryKey: aliveTasksQuery.queryKey,
|
||||
queryFn: async () => {
|
||||
const { aliveTasks } = await aliveTasksQuery.queryFn();
|
||||
setAliveTasks(aliveTasks);
|
||||
return aliveTasks;
|
||||
},
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const taskInfo = useMemo(() => {
|
||||
const aliveTask = aliveTasks.find((task) => task.sessionId === sessionId);
|
||||
|
||||
return {
|
||||
aliveTask: aliveTasks.find((task) => task.sessionId === sessionId),
|
||||
isRunningTask: aliveTask?.status === "running",
|
||||
isPausedTask: aliveTask?.status === "paused",
|
||||
} as const;
|
||||
}, [aliveTasks, sessionId]);
|
||||
|
||||
return taskInfo;
|
||||
};
|
||||
@@ -3,9 +3,13 @@ import { useSessionQuery } from "./useSessionQuery";
|
||||
|
||||
export const useSession = (projectId: string, sessionId: string) => {
|
||||
const query = useSessionQuery(projectId, sessionId);
|
||||
const session = query.data?.session;
|
||||
if (session === undefined || session === null) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const toolResultMap = useMemo(() => {
|
||||
const entries = query.data.session.conversations.flatMap((conversation) => {
|
||||
const entries = session.conversations.flatMap((conversation) => {
|
||||
if (conversation.type !== "user") {
|
||||
return [];
|
||||
}
|
||||
@@ -28,7 +32,7 @@ export const useSession = (projectId: string, sessionId: string) => {
|
||||
});
|
||||
|
||||
return new Map(entries);
|
||||
}, [query.data.session.conversations]);
|
||||
}, [session.conversations]);
|
||||
|
||||
const getToolResult = useCallback(
|
||||
(toolUseId: string) => {
|
||||
@@ -38,8 +42,8 @@ export const useSession = (projectId: string, sessionId: string) => {
|
||||
);
|
||||
|
||||
return {
|
||||
session: query.data.session,
|
||||
conversations: query.data.session.conversations,
|
||||
session,
|
||||
conversations: session.conversations,
|
||||
getToolResult,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { sessionProcessesAtom } from "../store/sessionProcessesAtom";
|
||||
|
||||
export const useSessionProcess = () => {
|
||||
const sessionProcesses = useAtomValue(sessionProcessesAtom);
|
||||
|
||||
const getSessionProcess = useCallback(
|
||||
(sessionId: string) => {
|
||||
const targetProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === sessionId,
|
||||
);
|
||||
|
||||
return targetProcess;
|
||||
},
|
||||
[sessionProcesses],
|
||||
);
|
||||
|
||||
return {
|
||||
sessionProcesses,
|
||||
getSessionProcess,
|
||||
};
|
||||
};
|
||||
15
src/app/projects/[projectId]/sessions/[sessionId]/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
interface SessionLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SessionLayout: FC<SessionLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionLayout;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MessageCircleOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function SessionNotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageCircleOff className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Session Not Found</CardTitle>
|
||||
<CardDescription>
|
||||
The conversation session you are looking for does not exist or
|
||||
has been removed
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="default">
|
||||
<Link href="/projects">
|
||||
<MessageCircleOff />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
|
||||
|
||||
export const aliveTasksAtom = atom<SerializableAliveTask[]>([]);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import type { PublicSessionProcess } from "../../../../../../types/session-process";
|
||||
|
||||
export const sessionProcessesAtom = atom<PublicSessionProcess[]>([]);
|
||||
105
src/app/projects/components/CreateProjectDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { honoClient } from "@/lib/api/client";
|
||||
import { DirectoryPicker } from "./DirectoryPicker";
|
||||
|
||||
export const CreateProjectDialog: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await honoClient.api.projects.$post({
|
||||
json: { projectPath: selectedPath },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create project");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
onSuccess: (result) => {
|
||||
toast.success("Project created successfully");
|
||||
setOpen(false);
|
||||
router.push(`/projects/${result.projectId}/sessions/${result.sessionId}`);
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create project",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a directory to initialize as a Claude Code project. This will
|
||||
run{" "}
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">/init</code>{" "}
|
||||
in the selected directory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<DirectoryPicker
|
||||
selectedPath={selectedPath}
|
||||
onPathChange={setSelectedPath}
|
||||
/>
|
||||
{selectedPath ? (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm font-medium mb-1">Selected directory:</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{selectedPath}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => await createProjectMutation.mutateAsync()}
|
||||
disabled={!selectedPath || createProjectMutation.isPending}
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
76
src/app/projects/components/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Folder } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { directoryListingQuery } from "@/lib/api/queries";
|
||||
|
||||
export type DirectoryPickerProps = {
|
||||
selectedPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
};
|
||||
|
||||
export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading } = useQuery(directoryListingQuery(currentPath));
|
||||
|
||||
const handleNavigate = (entryPath: string) => {
|
||||
if (entryPath === "") {
|
||||
setCurrentPath(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPath = `/${entryPath}`;
|
||||
setCurrentPath(newPath);
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
onPathChange(data?.currentPath || "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<div className="p-3 border-b bg-muted/50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
Current: <span className="font-mono">{data?.currentPath || "~"}</span>
|
||||
</p>
|
||||
<Button size="sm" onClick={handleSelect}>
|
||||
Select This Directory
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : data?.entries && data.entries.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{data.entries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => handleNavigate(entry.path)}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{entry.name === ".." ? (
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
<span className="text-sm">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
No directories found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -49,8 +49,8 @@ export const ProjectList: FC = () => {
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last modified:{" "}
|
||||
{project.meta.lastModifiedAt
|
||||
? new Date(project.meta.lastModifiedAt).toLocaleDateString()
|
||||
{project.lastModifiedAt
|
||||
? new Date(project.lastModifiedAt).toLocaleDateString()
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -59,8 +59,8 @@ export const ProjectList: FC = () => {
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/projects/${encodeURIComponent(project.id)}`}>
|
||||
View Sessions
|
||||
<Link href={`/projects/${project.id}/latest`}>
|
||||
View Conversations
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
"use client";
|
||||
|
||||
import { HistoryIcon } from "lucide-react";
|
||||
import { projectListQuery } from "../../lib/api/queries";
|
||||
import { Suspense } from "react";
|
||||
import { GlobalSidebar } from "@/components/GlobalSidebar";
|
||||
import { CreateProjectDialog } from "./components/CreateProjectDialog";
|
||||
import { ProjectList } from "./components/ProjectList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: projectListQuery.queryKey,
|
||||
queryFn: projectListQuery.queryFn,
|
||||
});
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
|
||||
<HistoryIcon className="w-8 h-8" />
|
||||
Claude Code Viewer
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse your Claude Code conversation history and project interactions
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">
|
||||
<GlobalSidebar className="hidden md:flex" />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
|
||||
<HistoryIcon className="w-8 h-8" />
|
||||
Claude Code Viewer
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse your Claude Code conversation history and project
|
||||
interactions
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Your Projects</h2>
|
||||
|
||||
<ProjectList />
|
||||
</section>
|
||||
</main>
|
||||
<main>
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Your Projects</h2>
|
||||
<CreateProjectDialog />
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
Loading projects...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProjectList />
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
150
src/components/GlobalSidebar.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, Suspense, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { SettingsControls } from "./SettingsControls";
|
||||
|
||||
export interface SidebarTab {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface GlobalSidebarProps {
|
||||
projectId?: string;
|
||||
className?: string;
|
||||
additionalTabs?: SidebarTab[];
|
||||
defaultActiveTab?: string;
|
||||
headerButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
projectId,
|
||||
className,
|
||||
additionalTabs = [],
|
||||
defaultActiveTab,
|
||||
headerButton,
|
||||
}) => {
|
||||
const settingsTab: SidebarTab = {
|
||||
id: "settings",
|
||||
icon: SettingsIcon,
|
||||
title: "表示と通知の設定",
|
||||
content: (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
<h2 className="font-semibold text-lg">Settings</h2>
|
||||
<p className="text-xs text-sidebar-foreground/70">
|
||||
Display and behavior preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-sm text-sidebar-foreground/70">
|
||||
Loading settings...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Session Display
|
||||
</h3>
|
||||
<SettingsControls openingProjectId={projectId ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
</h3>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const allTabs = [...additionalTabs, settingsTab];
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultActiveTab ?? allTabs[allTabs.length - 1]?.id ?? "settings",
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(!!defaultActiveTab);
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
if (activeTab === tabId && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
} else {
|
||||
setActiveTab(tabId);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const activeTabContent = allTabs.find((tab) => tab.id === activeTab)?.content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out flex bg-sidebar text-sidebar-foreground",
|
||||
isExpanded ? "w-72 lg:w-80" : "w-12",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Vertical Icon Menu - Always Visible */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<TooltipProvider>
|
||||
{headerButton && (
|
||||
<div className="border-b border-sidebar-border">{headerButton}</div>
|
||||
)}
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
{allTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Tooltip key={tab.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
activeTab === tab.id && isExpanded
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
data-testid={`${tab.id}-tab-button`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{tab.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Only shown when expanded */}
|
||||
{isExpanded && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{activeTabContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTheme } from "next-themes";
|
||||
import { type FC, useCallback, useId } from "react";
|
||||
import { useConfig } from "@/app/hooks/useConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -33,17 +34,19 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
const checkboxId = useId();
|
||||
const enterKeyBehaviorId = useId();
|
||||
const permissionModeId = useId();
|
||||
const themeId = useId();
|
||||
const { config, updateConfig } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const onConfigChanged = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: configQuery.queryKey,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: projectListQuery.queryKey,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: projectDetailQuery(openingProjectId).queryKey,
|
||||
});
|
||||
}, [queryClient, openingProjectId]);
|
||||
@@ -69,7 +72,10 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
const handleEnterKeyBehaviorChange = async (value: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
enterKeyBehavior: value as "shift-enter-send" | "enter-send",
|
||||
enterKeyBehavior: value as
|
||||
| "shift-enter-send"
|
||||
| "enter-send"
|
||||
| "command-enter-send",
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
@@ -154,6 +160,9 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
Shift+Enter to send (default)
|
||||
</SelectItem>
|
||||
<SelectItem value="enter-send">Enter to send</SelectItem>
|
||||
<SelectItem value="command-enter-send">
|
||||
Command+Enter to send
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
@@ -197,6 +206,29 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{showLabels && (
|
||||
<label htmlFor={themeId} className="text-sm font-medium leading-none">
|
||||
Theme
|
||||
</label>
|
||||
)}
|
||||
<Select value={theme || "system"} onValueChange={setTheme}>
|
||||
<SelectTrigger id={themeId} className="w-full">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose your preferred color theme
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -15,7 +15,7 @@ export const usePermissionRequests = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Listen for permission requests from the server
|
||||
useServerEventListener("permission_requested", (data) => {
|
||||
useServerEventListener("permissionRequested", (data) => {
|
||||
if (data.permissionRequest) {
|
||||
setCurrentPermissionRequest(data.permissionRequest);
|
||||
setIsDialogOpen(true);
|
||||
@@ -25,7 +25,7 @@ export const usePermissionRequests = () => {
|
||||
const handlePermissionResponse = useCallback(
|
||||
async (response: PermissionResponse) => {
|
||||
try {
|
||||
const apiResponse = await honoClient.api.tasks[
|
||||
const apiResponse = await honoClient.api.cc[
|
||||
"permission-response"
|
||||
].$post({
|
||||
json: response,
|
||||
|
||||
@@ -23,7 +23,6 @@ export const makeQueryClient = () =>
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 1000 * 60 * 5,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { hc } from "hono/client";
|
||||
import type { RouteType } from "../../server/hono/route";
|
||||
import { env } from "../../server/lib/env";
|
||||
|
||||
export const honoClient = hc<RouteType>(
|
||||
typeof window === "undefined" ? `http://localhost:${env.get("PORT")}/` : "/",
|
||||
typeof window === "undefined"
|
||||
? // biome-ignore lint/complexity/useLiteralKeys: allow here
|
||||
// biome-ignore lint/style/noProcessEnv: allow here
|
||||
`http://localhost:${process.env["PORT"]}/`
|
||||
: "/",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion";
|
||||
import type { DirectoryListingResult } from "../../server/core/file-system/functions/getDirectoryListing";
|
||||
import type { FileCompletionResult } from "../../server/core/file-system/functions/getFileCompletion";
|
||||
import { honoClient } from "./client";
|
||||
|
||||
export const projectListQuery = {
|
||||
@@ -16,12 +17,29 @@ export const projectListQuery = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const projectDetailQuery = (projectId: string) =>
|
||||
export const directoryListingQuery = (currentPath?: string) =>
|
||||
({
|
||||
queryKey: ["directory-listing", currentPath],
|
||||
queryFn: async (): Promise<DirectoryListingResult> => {
|
||||
const response = await honoClient.api.fs["directory-browser"].$get({
|
||||
query: currentPath ? { currentPath } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch directory listing");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const projectDetailQuery = (projectId: string, cursor?: string) =>
|
||||
({
|
||||
queryKey: ["projects", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[":projectId"].$get({
|
||||
param: { projectId },
|
||||
query: { cursor },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -32,6 +50,26 @@ export const projectDetailQuery = (projectId: string) =>
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const latestSessionQuery = (projectId: string) =>
|
||||
({
|
||||
queryKey: ["projects", projectId, "latest-session"],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"latest-session"
|
||||
].$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch latest session: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const sessionDetailQuery = (projectId: string, sessionId: string) =>
|
||||
({
|
||||
queryKey: ["projects", projectId, "sessions", sessionId],
|
||||
@@ -49,7 +87,7 @@ export const sessionDetailQuery = (projectId: string, sessionId: string) =>
|
||||
throw new Error(`Failed to fetch session: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
@@ -73,10 +111,10 @@ export const claudeCommandsQuery = (projectId: string) =>
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const aliveTasksQuery = {
|
||||
queryKey: ["aliveTasks"],
|
||||
export const sessionProcessesQuery = {
|
||||
queryKey: ["sessionProcesses"],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.tasks.alive.$get({});
|
||||
const response = await honoClient.api.cc["session-processes"].$get({});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch alive tasks: ${response.statusText}`);
|
||||
@@ -122,35 +160,37 @@ export const gitCommitsQuery = (projectId: string) =>
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const mcpListQuery = {
|
||||
queryKey: ["mcp", "list"],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.mcp.list.$get();
|
||||
export const mcpListQuery = (projectId: string) =>
|
||||
({
|
||||
queryKey: ["mcp", "list", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].mcp.list.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
} as const;
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const fileCompletionQuery = (projectId: string, basePath: string) =>
|
||||
({
|
||||
queryKey: ["file-completion", projectId, basePath],
|
||||
queryFn: async (): Promise<FileCompletionResult> => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"file-completion"
|
||||
].$get({
|
||||
param: { projectId },
|
||||
query: { basePath },
|
||||
const response = await honoClient.api.fs["file-completion"].$get({
|
||||
query: { basePath, projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch file completion");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
|
||||
25
src/lib/controllablePromise.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type ControllablePromise<T> = {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
export const controllablePromise = <T>(): ControllablePromise<T> => {
|
||||
let promiseResolve: ((value: T) => void) | undefined;
|
||||
let promiseReject: ((reason?: unknown) => void) | undefined;
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
promiseResolve = resolve;
|
||||
promiseReject = reject;
|
||||
});
|
||||
|
||||
if (!promiseResolve || !promiseReject) {
|
||||
throw new Error("Illegal state: Promise not created");
|
||||
}
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: promiseResolve,
|
||||
reject: promiseReject,
|
||||
} as const;
|
||||
};
|
||||
@@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({
|
||||
// required
|
||||
message: UserMessageSchema,
|
||||
});
|
||||
|
||||
export type UserEntry = z.infer<typeof UserEntrySchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SSEEvent } from "../../../types/sse";
|
||||
import { type EventListener, useSSEContext } from "../SSEContext";
|
||||
|
||||
@@ -6,19 +6,24 @@ import { type EventListener, useSSEContext } from "../SSEContext";
|
||||
* Custom hook to listen for specific SSE events
|
||||
* @param eventType - The type of event to listen for
|
||||
* @param listener - The callback function to execute when the event is received
|
||||
* @param deps - Dependencies array for the listener function (similar to useEffect)
|
||||
*/
|
||||
export const useServerEventListener = <T extends SSEEvent["kind"]>(
|
||||
eventType: T,
|
||||
listener: EventListener<T>,
|
||||
deps?: React.DependencyList,
|
||||
) => {
|
||||
const { addEventListener } = useSSEContext();
|
||||
const listenerRef = useRef(listener);
|
||||
|
||||
useEffect(() => {
|
||||
const removeEventListener = addEventListener(eventType, listener);
|
||||
listenerRef.current = listener;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const removeEventListener = addEventListener(eventType, (event) => {
|
||||
listenerRef.current(event);
|
||||
});
|
||||
return () => {
|
||||
removeEventListener();
|
||||
};
|
||||
}, [eventType, addEventListener, listener, ...(deps ?? [])]);
|
||||
}, [eventType, addEventListener]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { Path } from "@effect/platform";
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeClaudeProjectFilePath } from "./computeClaudeProjectFilePath";
|
||||
|
||||
describe("computeClaudeProjectFilePath", () => {
|
||||
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
|
||||
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
|
||||
|
||||
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
|
||||
const projectPath = "/home/me/dev/example";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
}).pipe(Effect.provide(Path.layer)),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("末尾にスラッシュがある場合も正しく処理される", async () => {
|
||||
const projectPath = "/home/me/dev/example/";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
}).pipe(Effect.provide(Path.layer)),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Path } from "@effect/platform";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export const computeClaudeProjectFilePath = (options: {
|
||||
projectPath: string;
|
||||
claudeProjectsDirPath: string;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const path = yield* Path.Path;
|
||||
const { projectPath, claudeProjectsDirPath } = options;
|
||||
|
||||
return path.join(
|
||||
claudeProjectsDirPath,
|
||||
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import { controllablePromise } from "../../../../lib/controllablePromise";
|
||||
|
||||
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
|
||||
|
||||
export type MessageGenerator = () => AsyncGenerator<
|
||||
SDKUserMessage,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export const createMessageGenerator = (): {
|
||||
generateMessages: MessageGenerator;
|
||||
setNextMessage: (message: string) => void;
|
||||
setHooks: (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => void;
|
||||
} => {
|
||||
let sendMessagePromise = controllablePromise<string>();
|
||||
let registeredHooks: {
|
||||
onNextMessageSet: ((message: string) => void | Promise<void>)[];
|
||||
onNewUserMessageResolved: ((message: string) => void | Promise<void>)[];
|
||||
} = {
|
||||
onNextMessageSet: [],
|
||||
onNewUserMessageResolved: [],
|
||||
};
|
||||
|
||||
const createMessage = (message: string): SDKUserMessage => {
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: message,
|
||||
},
|
||||
} as SDKUserMessage;
|
||||
};
|
||||
|
||||
async function* generateMessages(): ReturnType<MessageGenerator> {
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
|
||||
while (true) {
|
||||
const message = await sendMessagePromise.promise;
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)),
|
||||
);
|
||||
|
||||
yield createMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
const setNextMessage = (message: string) => {
|
||||
sendMessagePromise.resolve(message);
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNextMessageSet.map((hook) => hook(message)),
|
||||
);
|
||||
};
|
||||
|
||||
const setHooks = (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => {
|
||||
registeredHooks = {
|
||||
onNextMessageSet: [
|
||||
...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []),
|
||||
...registeredHooks.onNextMessageSet,
|
||||
],
|
||||
onNewUserMessageResolved: [
|
||||
...(hooks?.onNewUserMessageResolved
|
||||
? [hooks.onNewUserMessageResolved]
|
||||
: []),
|
||||
...registeredHooks.onNewUserMessageResolved,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setHooks,
|
||||
};
|
||||
};
|
||||
378
src/server/core/claude-code/functions/parseJsonl.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ErrorJsonl } from "../../types";
|
||||
import { parseJsonl } from "./parseJsonl";
|
||||
|
||||
describe("parseJsonl", () => {
|
||||
describe("正常系: 有効なJSONLをパースできる", () => {
|
||||
it("単一のUserエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.message.content).toBe("Hello");
|
||||
}
|
||||
});
|
||||
|
||||
it("単一のSummaryエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "This is a summary",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440003",
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "summary");
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "summary") {
|
||||
expect(entry.summary).toBe("This is a summary");
|
||||
}
|
||||
});
|
||||
|
||||
it("複数のエントリをパースできる", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Test summary",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440002",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系: 不正なJSON行をErrorJsonlとして返す", () => {
|
||||
it("無効なJSONを渡すとエラーを投げる", () => {
|
||||
const jsonl = "invalid json";
|
||||
|
||||
// parseJsonl の実装は JSON.parse をそのまま呼び出すため、
|
||||
// 無効な JSON は例外を投げます
|
||||
expect(() => parseJsonl(jsonl)).toThrow();
|
||||
});
|
||||
|
||||
it("スキーマに合わないオブジェクトをErrorJsonlとして返す", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "unknown",
|
||||
someField: "value",
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const errorEntry = result[0] as ErrorJsonl;
|
||||
expect(errorEntry.type).toBe("x-error");
|
||||
expect(errorEntry.lineNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("必須フィールドが欠けているエントリをErrorJsonlとして返す", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
// timestamp, message などの必須フィールドが欠けている
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const errorEntry = result[0] as ErrorJsonl;
|
||||
expect(errorEntry.type).toBe("x-error");
|
||||
expect(errorEntry.lineNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("正常なエントリとエラーエントリを混在して返す", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "invalid-schema" }),
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "x-error");
|
||||
expect(result[2]).toHaveProperty("type", "summary");
|
||||
|
||||
const errorEntry = result[1] as ErrorJsonl;
|
||||
expect(errorEntry.lineNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース: 空行、トリム、複数エントリ", () => {
|
||||
it("空文字列を渡すと空配列を返す", () => {
|
||||
const result = parseJsonl("");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("空行のみを渡すと空配列を返す", () => {
|
||||
const result = parseJsonl("\n\n\n");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("前後の空白をトリムする", () => {
|
||||
const jsonl = `
|
||||
${JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
})}
|
||||
`;
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
});
|
||||
|
||||
it("行間の空行を除外する", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
"",
|
||||
"",
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
|
||||
it("空白のみの行を除外する", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
" ",
|
||||
"\t",
|
||||
JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Summary text",
|
||||
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("type", "user");
|
||||
expect(result[1]).toHaveProperty("type", "summary");
|
||||
});
|
||||
|
||||
it("多数のエントリを含むJSONLをパースできる", () => {
|
||||
const entries = Array.from({ length: 100 }, (_, i) => {
|
||||
return JSON.stringify({
|
||||
type: "user",
|
||||
uuid: `550e8400-e29b-41d4-a716-${String(i).padStart(12, "0")}`,
|
||||
timestamp: new Date(Date.UTC(2024, 0, 1, 0, 0, i)).toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: `Message ${i}`,
|
||||
},
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid:
|
||||
i > 0
|
||||
? `550e8400-e29b-41d4-a716-${String(i - 1).padStart(12, "0")}`
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
const jsonl = entries.join("\n");
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(100);
|
||||
expect(result.every((entry) => entry.type === "user")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("行番号の正確性", () => {
|
||||
it("スキーマ検証エラー時の行番号が正確に記録される", () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Line 1" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "invalid", data: "schema error" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
timestamp: "2024-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "Line 3" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
}),
|
||||
JSON.stringify({ type: "another-invalid" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect((result[1] as ErrorJsonl).lineNumber).toBe(2);
|
||||
expect((result[1] as ErrorJsonl).type).toBe("x-error");
|
||||
expect((result[3] as ErrorJsonl).lineNumber).toBe(4);
|
||||
expect((result[3] as ErrorJsonl).type).toBe("x-error");
|
||||
});
|
||||
|
||||
it("空行フィルタ後の行番号が正確に記録される", () => {
|
||||
const jsonl = ["", "", JSON.stringify({ type: "invalid-schema" })].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// 空行がフィルタされた後のインデックスは0だが、lineNumberは1として記録される
|
||||
expect((result[0] as ErrorJsonl).lineNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConversationSchemaのバリエーション", () => {
|
||||
it("オプショナルフィールドを含むUserエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: true,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: "550e8400-e29b-41d4-a716-446655440099",
|
||||
gitBranch: "main",
|
||||
isMeta: false,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.isSidechain).toBe(true);
|
||||
expect(entry.parentUuid).toBe("550e8400-e29b-41d4-a716-446655440099");
|
||||
expect(entry.gitBranch).toBe("main");
|
||||
}
|
||||
});
|
||||
|
||||
it("nullableフィールドがnullのエントリをパースできる", () => {
|
||||
const jsonl = JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "session-1",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
});
|
||||
|
||||
const result = parseJsonl(jsonl);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const entry = result[0];
|
||||
if (entry && entry.type === "user") {
|
||||
expect(entry.parentUuid).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,19 @@
|
||||
import { ConversationSchema } from "../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "./types";
|
||||
import { ConversationSchema } from "../../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl, ExtendedConversation } from "../../types";
|
||||
|
||||
export const parseJsonl = (content: string) => {
|
||||
export const parseJsonl = (content: string): ExtendedConversation[] => {
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
return lines.map((line) => {
|
||||
return lines.map((line, index) => {
|
||||
const parsed = ConversationSchema.safeParse(JSON.parse(line));
|
||||
if (!parsed.success) {
|
||||
console.warn("Failed to parse jsonl, skipping", parsed.error);
|
||||
const errorData: ErrorJsonl = {
|
||||
type: "x-error",
|
||||
line,
|
||||
lineNumber: index + 1,
|
||||
};
|
||||
return errorData;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMcpListOutput } from "./parseMcpListOutput";
|
||||
|
||||
describe("parseMcpListOutput", () => {
|
||||
it("should parse claude mcp list output correctly", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple MCP servers", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
filesystem: /usr/local/bin/mcp-server-fs - ✓ Connected
|
||||
database: docker run db-mcp - ✗ Failed
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "/usr/local/bin/mcp-server-fs",
|
||||
},
|
||||
{
|
||||
name: "database",
|
||||
command: "docker run db-mcp",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array for output with no MCP servers", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip malformed lines", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
invalid line without colon
|
||||
: command without name
|
||||
name without command:
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
32
src/server/core/claude-code/functions/parseMcpListOutput.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const parseMcpListOutput = (output: string) => {
|
||||
const servers: McpServer[] = [];
|
||||
const lines = output.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines and status indicators
|
||||
if (line.includes("Checking MCP server health") || line.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected"
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove status indicators (✓ Connected, ✗ Failed, etc.)
|
||||
const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim();
|
||||
|
||||
if (name && command) {
|
||||
servers.push({ name, command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
};
|
||||
301
src/server/core/claude-code/functions/parseUserMessage.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { parseUserMessage } from "./parseUserMessage";
|
||||
|
||||
describe("parseCommandXml", () => {
|
||||
describe("command parsing", () => {
|
||||
it("parses command-name only", () => {
|
||||
const input = "<command-name>git status</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "git status",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command-name with command-args", () => {
|
||||
const input =
|
||||
"<command-name>git commit</command-name><command-args>-m 'test'</command-args>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "git commit",
|
||||
commandArgs: "-m 'test'",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command-name with command-message", () => {
|
||||
const input =
|
||||
"<command-name>ls</command-name><command-message>Listing files</command-message>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "ls",
|
||||
commandArgs: undefined,
|
||||
commandMessage: "Listing files",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses all command tags together", () => {
|
||||
const input =
|
||||
"<command-name>npm install</command-name><command-args>--save-dev vitest</command-args><command-message>Installing test dependencies</command-message>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "npm install",
|
||||
commandArgs: "--save-dev vitest",
|
||||
commandMessage: "Installing test dependencies",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command tags with whitespace in content", () => {
|
||||
const input =
|
||||
"<command-name>\n git status \n</command-name><command-args> --short </command-args>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "\n git status \n",
|
||||
commandArgs: " --short ",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses command tags in different order", () => {
|
||||
const input =
|
||||
"<command-message>Test message</command-message><command-args>-v</command-args><command-name>test command</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test command",
|
||||
commandArgs: "-v",
|
||||
commandMessage: "Test message",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-command parsing", () => {
|
||||
it("parses local-command-stdout", () => {
|
||||
const input = "<local-command-stdout>output text</local-command-stdout>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: "output text",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local-command-stdout with multiline content", () => {
|
||||
const input =
|
||||
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: "line1\nline2\nline3",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local-command-stdout with whitespace", () => {
|
||||
const input =
|
||||
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// The regex pattern preserves all whitespace in content
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
stdout: " \n output with spaces \n ",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority: command over local-command", () => {
|
||||
it("returns command when both command and local-command tags exist", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name><local-command-stdout>output</local-command-stdout>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("test");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback to text", () => {
|
||||
it("returns text when no matching tags found", () => {
|
||||
const input = "just plain text";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "just plain text",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text when tags are not closed properly", () => {
|
||||
const input = "<command-name>incomplete";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<command-name>incomplete",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text when tags are mismatched", () => {
|
||||
const input = "<command-name>test</different-tag>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<command-name>test</different-tag>",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with empty string", () => {
|
||||
const input = "";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with only unrecognized tags", () => {
|
||||
const input = "<unknown-tag>content</unknown-tag>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
content: "<unknown-tag>content</unknown-tag>",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles multiple same tags (uses first match)", () => {
|
||||
const input =
|
||||
"<command-name>first</command-name><command-name>second</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("first");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty tag content", () => {
|
||||
const input = "<command-name></command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles tags with special characters in content", () => {
|
||||
const input =
|
||||
"<command-name>git commit -m 'test & demo'</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("git commit -m 'test & demo'");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match nested tags (regex limitation)", () => {
|
||||
const input = "<command-name><nested>inner</nested>outer</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// The regex won't match properly nested tags due to [^<]* pattern
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles tags with surrounding text", () => {
|
||||
const input =
|
||||
"Some text before <command-name>test</command-name> and after";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles newlines between tags", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
commandArgs: "arg",
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles very long content", () => {
|
||||
const longContent = "x".repeat(10000);
|
||||
const input = `<command-name>${longContent}</command-name>`;
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe(longContent);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles tags with attributes (not matched)", () => {
|
||||
const input = '<command-name attr="value">test</command-name>';
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles self-closing tags (not matched)", () => {
|
||||
const input = "<command-name />";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles Unicode content", () => {
|
||||
const input = "<command-name>テスト コマンド 🚀</command-name>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
commandName: "テスト コマンド 🚀",
|
||||
commandArgs: undefined,
|
||||
commandMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed content with multiple tag types", () => {
|
||||
const input =
|
||||
"Some text <command-name>cmd</command-name> more text <unknown>tag</unknown>";
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
expect(result.commandName).toBe("cmd");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,23 +7,26 @@ const matchSchema = z.object({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export type ParsedCommand =
|
||||
| {
|
||||
kind: "command";
|
||||
commandName: string;
|
||||
commandArgs?: string;
|
||||
commandMessage?: string;
|
||||
}
|
||||
| {
|
||||
kind: "local-command";
|
||||
stdout: string;
|
||||
}
|
||||
| {
|
||||
kind: "text";
|
||||
content: string;
|
||||
};
|
||||
export const parsedUserMessageSchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal("command"),
|
||||
commandName: z.string(),
|
||||
commandArgs: z.string().optional(),
|
||||
commandMessage: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("local-command"),
|
||||
stdout: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("text"),
|
||||
content: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const parseCommandXml = (content: string): ParsedCommand => {
|
||||
export type ParsedUserMessage = z.infer<typeof parsedUserMessageSchema>;
|
||||
|
||||
export const parseUserMessage = (content: string): ParsedUserMessage => {
|
||||
const matches = Array.from(content.matchAll(regExp))
|
||||
.map((match) => matchSchema.safeParse(match.groups))
|
||||
.filter((result) => result.success)
|
||||
121
src/server/core/claude-code/models/CCSessionProcess.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Effect } from "effect";
|
||||
import type { UserEntry } from "../../../../lib/conversation-schema/entry/UserEntrySchema";
|
||||
import type { InitMessageContext } from "../types";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
import type * as CCTask from "./ClaudeCodeTask";
|
||||
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
|
||||
|
||||
export type CCSessionProcessDef = {
|
||||
sessionProcessId: string;
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
abortController: AbortController;
|
||||
setNextMessage: (message: string) => void;
|
||||
};
|
||||
|
||||
type CCSessionProcessStateBase = {
|
||||
def: CCSessionProcessDef;
|
||||
tasks: CCTask.ClaudeCodeTaskState[];
|
||||
};
|
||||
|
||||
export type CCSessionProcessPendingState = CCSessionProcessStateBase & {
|
||||
type: "pending" /* メッセージがまだ解決されていない状態 */;
|
||||
sessionId?: undefined;
|
||||
currentTask: CCTask.PendingClaudeCodeTaskState;
|
||||
};
|
||||
|
||||
export type CCSessionProcessNotInitializedState = CCSessionProcessStateBase & {
|
||||
type: "not_initialized" /* メッセージは解決されているが、init メッセージを未受信 */;
|
||||
sessionId?: undefined;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
};
|
||||
|
||||
export type CCSessionProcessInitializedState = CCSessionProcessStateBase & {
|
||||
type: "initialized" /* init メッセージを受信した状態 */;
|
||||
sessionId: string;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
initContext: InitMessageContext;
|
||||
};
|
||||
|
||||
export type CCSessionProcessFileCreatedState = CCSessionProcessStateBase & {
|
||||
type: "file_created" /* ファイルが作成された状態 */;
|
||||
sessionId: string;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
initContext: InitMessageContext;
|
||||
};
|
||||
|
||||
export type CCSessionProcessPausedState = CCSessionProcessStateBase & {
|
||||
type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type CCSessionProcessCompletedState = CCSessionProcessStateBase & {
|
||||
type: "completed" /* paused あるいは起動中のタスクが中断された状態。再開不可 */;
|
||||
sessionId?: string | undefined;
|
||||
};
|
||||
|
||||
export type CCSessionProcessStatePublic =
|
||||
| CCSessionProcessInitializedState
|
||||
| CCSessionProcessFileCreatedState
|
||||
| CCSessionProcessPausedState;
|
||||
|
||||
export type CCSessionProcessState =
|
||||
| CCSessionProcessPendingState
|
||||
| CCSessionProcessNotInitializedState
|
||||
| CCSessionProcessStatePublic
|
||||
| CCSessionProcessCompletedState;
|
||||
|
||||
export const isPublic = (
|
||||
process: CCSessionProcessState,
|
||||
): process is CCSessionProcessStatePublic => {
|
||||
return (
|
||||
process.type === "initialized" ||
|
||||
process.type === "file_created" ||
|
||||
process.type === "paused"
|
||||
);
|
||||
};
|
||||
|
||||
export const getAliveTasks = (
|
||||
process: CCSessionProcessState,
|
||||
): CCTask.AliveClaudeCodeTaskState[] => {
|
||||
return process.tasks.filter(
|
||||
(task) => task.status === "pending" || task.status === "running",
|
||||
);
|
||||
};
|
||||
|
||||
export const createVirtualConversation = (
|
||||
process: CCSessionProcessState,
|
||||
ctx: {
|
||||
sessionId: string;
|
||||
userMessage: string;
|
||||
},
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const config = yield* ClaudeCode.Config;
|
||||
|
||||
const virtualConversation: UserEntry = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: ctx.userMessage,
|
||||
},
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: process.def.cwd,
|
||||
sessionId: ctx.sessionId,
|
||||
version: config.claudeCodeVersion
|
||||
? ClaudeCodeVersion.versionText(config.claudeCodeVersion)
|
||||
: "unknown",
|
||||
uuid: `vc__${ctx.sessionId}__${timestamp}`,
|
||||
timestamp,
|
||||
parentUuid: null,
|
||||
};
|
||||
|
||||
return virtualConversation;
|
||||
});
|
||||
};
|
||||
96
src/server/core/claude-code/models/ClaudeCode.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CommandExecutor, Path } from "@effect/platform";
|
||||
import { NodeContext } from "@effect/platform-node";
|
||||
import { Effect, Layer } from "effect";
|
||||
import { EnvService } from "../../platform/services/EnvService";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
|
||||
describe("ClaudeCode.Config", () => {
|
||||
describe("when environment variable CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH is not set", () => {
|
||||
it("should correctly parse results of 'which claude' and 'claude --version'", async () => {
|
||||
const CommandExecutorTest = Layer.effect(
|
||||
CommandExecutor.CommandExecutor,
|
||||
Effect.map(CommandExecutor.CommandExecutor, (realExecutor) => ({
|
||||
...realExecutor,
|
||||
string: (() => {
|
||||
const responses = ["/path/to/claude", "1.0.53 (Claude Code)\n"];
|
||||
return () => Effect.succeed(responses.shift() ?? "");
|
||||
})(),
|
||||
})),
|
||||
).pipe(Layer.provide(NodeContext.layer));
|
||||
|
||||
const config = await Effect.runPromise(
|
||||
ClaudeCode.Config.pipe(
|
||||
Effect.provide(EnvService.Live),
|
||||
Effect.provide(Path.layer),
|
||||
Effect.provide(CommandExecutorTest),
|
||||
),
|
||||
);
|
||||
|
||||
expect(config.claudeCodeExecutablePath).toBe("/path/to/claude");
|
||||
|
||||
expect(config.claudeCodeVersion).toStrictEqual({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCode.AvailableFeatures", () => {
|
||||
describe("when claudeCodeVersion is null", () => {
|
||||
it("canUseTool and uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures(null);
|
||||
expect(features.canUseTool).toBe(false);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.81", () => {
|
||||
it("canUseTool should be false, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 81,
|
||||
});
|
||||
expect(features.canUseTool).toBe(false);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.82", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 82,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.85", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 85,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.86", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be true", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 86,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/server/core/claude-code/models/ClaudeCode.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { query as originalQuery } from "@anthropic-ai/claude-code";
|
||||
import { Command, Path } from "@effect/platform";
|
||||
import { Effect } from "effect";
|
||||
import { EnvService } from "../../platform/services/EnvService";
|
||||
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
|
||||
|
||||
type CCQuery = typeof originalQuery;
|
||||
type CCQueryPrompt = Parameters<CCQuery>[0]["prompt"];
|
||||
type CCQueryOptions = NonNullable<Parameters<CCQuery>[0]["options"]>;
|
||||
|
||||
export const Config = Effect.gen(function* () {
|
||||
const path = yield* Path.Path;
|
||||
const envService = yield* EnvService;
|
||||
|
||||
const specifiedExecutablePath = yield* envService.getEnv(
|
||||
"CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH",
|
||||
);
|
||||
|
||||
const claudeCodeExecutablePath =
|
||||
specifiedExecutablePath !== undefined
|
||||
? path.resolve(specifiedExecutablePath)
|
||||
: (yield* Command.string(
|
||||
Command.make("which", "claude").pipe(
|
||||
Command.env({
|
||||
PATH: yield* envService.getEnv("PATH"),
|
||||
}),
|
||||
Command.runInShell(true),
|
||||
),
|
||||
)).trim();
|
||||
|
||||
const claudeCodeVersion = ClaudeCodeVersion.fromCLIString(
|
||||
yield* Command.string(Command.make(claudeCodeExecutablePath, "--version")),
|
||||
);
|
||||
|
||||
return {
|
||||
claudeCodeExecutablePath,
|
||||
claudeCodeVersion,
|
||||
};
|
||||
});
|
||||
|
||||
export const getMcpListOutput = (projectCwd: string) =>
|
||||
Effect.gen(function* () {
|
||||
const { claudeCodeExecutablePath } = yield* Config;
|
||||
const output = yield* Command.string(
|
||||
Command.make(
|
||||
"cd",
|
||||
projectCwd,
|
||||
"&&",
|
||||
claudeCodeExecutablePath,
|
||||
"mcp",
|
||||
"list",
|
||||
).pipe(Command.runInShell(true)),
|
||||
);
|
||||
return output;
|
||||
});
|
||||
|
||||
export const getAvailableFeatures = (
|
||||
claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null,
|
||||
) => ({
|
||||
canUseTool:
|
||||
claudeCodeVersion !== null
|
||||
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 82,
|
||||
})
|
||||
: false,
|
||||
uuidOnSDKMessage:
|
||||
claudeCodeVersion !== null
|
||||
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 86,
|
||||
})
|
||||
: false,
|
||||
});
|
||||
|
||||
export const query = (prompt: CCQueryPrompt, options: CCQueryOptions) => {
|
||||
const { canUseTool, permissionMode, ...baseOptions } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const { claudeCodeExecutablePath, claudeCodeVersion } = yield* Config;
|
||||
const availableFeatures = getAvailableFeatures(claudeCodeVersion);
|
||||
|
||||
return originalQuery({
|
||||
prompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: claudeCodeExecutablePath,
|
||||
...baseOptions,
|
||||
...(availableFeatures.canUseTool
|
||||
? { canUseTool, permissionMode }
|
||||
: {
|
||||
permissionMode: "bypassPermissions",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
59
src/server/core/claude-code/models/ClaudeCodeTask.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
type BaseClaudeCodeTaskDef = {
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type NewClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "new";
|
||||
sessionId?: undefined;
|
||||
baseSessionId?: undefined;
|
||||
};
|
||||
|
||||
export type ContinueClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "continue";
|
||||
sessionId: string;
|
||||
baseSessionId: string;
|
||||
};
|
||||
|
||||
export type ResumeClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "resume";
|
||||
sessionId?: undefined;
|
||||
baseSessionId: string;
|
||||
};
|
||||
|
||||
export type ClaudeCodeTaskDef =
|
||||
| NewClaudeCodeTaskDef
|
||||
| ContinueClaudeCodeTaskDef
|
||||
| ResumeClaudeCodeTaskDef;
|
||||
|
||||
type ClaudeCodeTaskStateBase = {
|
||||
def: ClaudeCodeTaskDef;
|
||||
};
|
||||
|
||||
export type PendingClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "pending";
|
||||
sessionId?: undefined;
|
||||
};
|
||||
|
||||
export type RunningClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "running";
|
||||
sessionId?: undefined;
|
||||
};
|
||||
|
||||
export type CompletedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "completed";
|
||||
sessionId?: string | undefined;
|
||||
};
|
||||
|
||||
export type FailedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "failed";
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type AliveClaudeCodeTaskState =
|
||||
| PendingClaudeCodeTaskState
|
||||
| RunningClaudeCodeTaskState;
|
||||
|
||||
export type ClaudeCodeTaskState =
|
||||
| AliveClaudeCodeTaskState
|
||||
| CompletedClaudeCodeTaskState
|
||||
| FailedClaudeCodeTaskState;
|
||||
84
src/server/core/claude-code/models/ClaudeCodeVersion.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
|
||||
|
||||
describe("ClaudeCodeVersion.fromCLIString", () => {
|
||||
describe("with valid version string", () => {
|
||||
it("should correctly parse CLI output format: 'x.x.x (Claude Code)'", () => {
|
||||
const version = ClaudeCodeVersion.fromCLIString("1.0.53 (Claude Code)\n");
|
||||
expect(version).toStrictEqual({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with invalid version string", () => {
|
||||
it("should return null for non-version format strings", () => {
|
||||
const version = ClaudeCodeVersion.fromCLIString("invalid version");
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.versionText", () => {
|
||||
it("should convert version object to 'major.minor.patch' format string", () => {
|
||||
const text = ClaudeCodeVersion.versionText({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
expect(text).toBe("1.0.53");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.equals", () => {
|
||||
describe("with same version", () => {
|
||||
it("should return true", () => {
|
||||
const a = { major: 1, minor: 0, patch: 53 };
|
||||
const b = { major: 1, minor: 0, patch: 53 };
|
||||
expect(ClaudeCodeVersion.equals(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.greaterThan", () => {
|
||||
describe("when a is greater than b", () => {
|
||||
it("should return true when major is greater", () => {
|
||||
const a = { major: 2, minor: 0, patch: 0 };
|
||||
const b = { major: 1, minor: 9, patch: 99 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when major is same and minor is greater", () => {
|
||||
const a = { major: 1, minor: 1, patch: 0 };
|
||||
const b = { major: 1, minor: 0, patch: 99 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when major and minor are same and patch is greater", () => {
|
||||
const a = { major: 1, minor: 0, patch: 86 };
|
||||
const b = { major: 1, minor: 0, patch: 85 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a is less than or equal to b", () => {
|
||||
it("should return false for same version", () => {
|
||||
const a = { major: 1, minor: 0, patch: 53 };
|
||||
const b = { major: 1, minor: 0, patch: 53 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when a is less than b", () => {
|
||||
const a = { major: 1, minor: 0, patch: 81 };
|
||||
const b = { major: 1, minor: 0, patch: 82 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when major is less", () => {
|
||||
const a = { major: 1, minor: 9, patch: 99 };
|
||||
const b = { major: 2, minor: 0, patch: 0 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/server/core/claude-code/models/ClaudeCodeVersion.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const versionRegex = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/;
|
||||
const versionSchema = z
|
||||
.object({
|
||||
major: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
minor: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
patch: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
})
|
||||
.refine((data) =>
|
||||
[data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)),
|
||||
);
|
||||
|
||||
export type ClaudeCodeVersion = z.infer<typeof versionSchema>;
|
||||
|
||||
export const fromCLIString = (
|
||||
versionOutput: string,
|
||||
): ClaudeCodeVersion | null => {
|
||||
const groups = versionOutput.trim().match(versionRegex)?.groups;
|
||||
|
||||
if (groups === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = versionSchema.safeParse(groups);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const versionText = (version: ClaudeCodeVersion) =>
|
||||
`${version.major}.${version.minor}.${version.patch}`;
|
||||
|
||||
export const equals = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
|
||||
a.major === b.major && a.minor === b.minor && a.patch === b.patch;
|
||||
|
||||
export const greaterThan = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
|
||||
a.major > b.major ||
|
||||
(a.major === b.major &&
|
||||
(a.minor > b.minor || (a.minor === b.minor && a.patch > b.patch)));
|
||||
|
||||
export const greaterThanOrEqual = (
|
||||
a: ClaudeCodeVersion,
|
||||
b: ClaudeCodeVersion,
|
||||
) => equals(a, b) || greaterThan(a, b);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ApplicationContext } from "../../platform/services/ApplicationContext";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { ClaudeCodeService } from "../services/ClaudeCodeService";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeService = yield* ClaudeCodeService;
|
||||
const context = yield* ApplicationContext;
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const path = yield* Path.Path;
|
||||
|
||||
const getClaudeCommands = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
const globalCommands: string[] = yield* fs
|
||||
.readDirectory(context.claudeCodePaths.claudeCommandsDirPath)
|
||||
.pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.map((item) => item.replace(/\.md$/, "")),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (items) => items,
|
||||
onFailure: () => {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectCommands: string[] =
|
||||
project.meta.projectPath === null
|
||||
? []
|
||||
: yield* fs
|
||||
.readDirectory(
|
||||
path.resolve(project.meta.projectPath, ".claude", "commands"),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.map((item) => item.replace(/\.md$/, "")),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (items) => items,
|
||||
onFailure: () => {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
response: {
|
||||
globalCommands: globalCommands,
|
||||
projectCommands: projectCommands,
|
||||
defaultCommands: ["init", "compact"],
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const getMcpListRoute = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
const servers = yield* claudeCodeService.getMcpList(projectId);
|
||||
return {
|
||||
response: { servers },
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getClaudeCommands,
|
||||
getMcpListRoute,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodeController extends Context.Tag("ClaudeCodeController")<
|
||||
ClaudeCodeController,
|
||||
IClaudeCodeController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { PermissionResponse } from "../../../../types/permissions";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ClaudeCodePermissionService } from "../services/ClaudeCodePermissionService";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
|
||||
|
||||
const permissionResponse = (options: {
|
||||
permissionResponse: PermissionResponse;
|
||||
}) =>
|
||||
Effect.sync(() => {
|
||||
const { permissionResponse } = options;
|
||||
|
||||
Effect.runFork(
|
||||
claudeCodePermissionService.respondToPermissionRequest(
|
||||
permissionResponse,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
message: "Permission response received",
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
permissionResponse,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodePermissionController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodePermissionController extends Context.Tag(
|
||||
"ClaudeCodePermissionController",
|
||||
)<ClaudeCodePermissionController, IClaudeCodePermissionController>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { PublicSessionProcess } from "../../../../types/session-process";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { UserConfigService } from "../../platform/services/UserConfigService";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { ClaudeCodeLifeCycleService } from "../services/ClaudeCodeLifeCycleService";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
|
||||
const userConfigService = yield* UserConfigService;
|
||||
|
||||
const getSessionProcesses = () =>
|
||||
Effect.gen(function* () {
|
||||
const publicSessionProcesses =
|
||||
yield* claudeCodeLifeCycleService.getPublicSessionProcesses();
|
||||
|
||||
return {
|
||||
response: {
|
||||
processes: publicSessionProcesses.map(
|
||||
(p): PublicSessionProcess => ({
|
||||
id: p.def.sessionProcessId,
|
||||
projectId: p.def.projectId,
|
||||
sessionId: p.sessionId,
|
||||
status: p.type === "paused" ? "paused" : "running",
|
||||
}),
|
||||
),
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const createSessionProcess = (options: {
|
||||
projectId: string;
|
||||
message: string;
|
||||
baseSessionId?: string | undefined;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, message, baseSessionId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
const userConfig = yield* userConfigService.getUserConfig();
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400 as const,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.startTask({
|
||||
baseSession: {
|
||||
cwd: project.meta.projectPath,
|
||||
projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
userConfig,
|
||||
message,
|
||||
});
|
||||
|
||||
const { sessionId } = yield* result.yieldSessionInitialized();
|
||||
|
||||
return {
|
||||
status: 201 as const,
|
||||
response: {
|
||||
sessionProcess: {
|
||||
id: result.sessionProcess.def.sessionProcessId,
|
||||
projectId,
|
||||
sessionId,
|
||||
},
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const continueSessionProcess = (options: {
|
||||
projectId: string;
|
||||
continueMessage: string;
|
||||
baseSessionId: string;
|
||||
sessionProcessId: string;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, continueMessage, baseSessionId, sessionProcessId } =
|
||||
options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.continueTask({
|
||||
sessionProcessId,
|
||||
message: continueMessage,
|
||||
baseSessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
response: {
|
||||
sessionProcess: {
|
||||
id: result.sessionProcess.def.sessionProcessId,
|
||||
projectId: result.sessionProcess.def.projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getSessionProcesses,
|
||||
createSessionProcess,
|
||||
continueSessionProcess,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeSessionProcessController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodeSessionProcessController extends Context.Tag(
|
||||
"ClaudeCodeSessionProcessController",
|
||||
)<ClaudeCodeSessionProcessController, IClaudeCodeSessionProcessController>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import type { FileSystem, Path } from "@effect/platform";
|
||||
import type { CommandExecutor } from "@effect/platform/CommandExecutor";
|
||||
import { Context, Effect, Layer, Runtime } from "effect";
|
||||
import { ulid } from "ulid";
|
||||
import { controllablePromise } from "../../../../lib/controllablePromise";
|
||||
import type { UserConfig } from "../../../lib/config/config";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { EventBus } from "../../events/services/EventBus";
|
||||
import type { EnvService } from "../../platform/services/EnvService";
|
||||
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../../session/infrastructure/VirtualConversationDatabase";
|
||||
import type { SessionMetaService } from "../../session/services/SessionMetaService";
|
||||
import { createMessageGenerator } from "../functions/createMessageGenerator";
|
||||
import * as CCSessionProcess from "../models/CCSessionProcess";
|
||||
import * as ClaudeCode from "../models/ClaudeCode";
|
||||
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
|
||||
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
|
||||
|
||||
export type MessageGenerator = () => AsyncGenerator<
|
||||
SDKUserMessage,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const eventBusService = yield* EventBus;
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
const sessionProcessService = yield* ClaudeCodeSessionProcessService;
|
||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||
const permissionService = yield* ClaudeCodePermissionService;
|
||||
|
||||
const runtime = yield* Effect.runtime<
|
||||
| FileSystem.FileSystem
|
||||
| Path.Path
|
||||
| CommandExecutor
|
||||
| VirtualConversationDatabase
|
||||
| SessionMetaService
|
||||
| ClaudeCodePermissionService
|
||||
| EnvService
|
||||
>();
|
||||
|
||||
const continueTask = (options: {
|
||||
sessionProcessId: string;
|
||||
baseSessionId: string;
|
||||
message: string;
|
||||
}) => {
|
||||
const { sessionProcessId, baseSessionId, message } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const { sessionProcess, task } =
|
||||
yield* sessionProcessService.continueSessionProcess({
|
||||
sessionProcessId,
|
||||
taskDef: {
|
||||
type: "continue",
|
||||
sessionId: baseSessionId,
|
||||
baseSessionId: baseSessionId,
|
||||
taskId: ulid(),
|
||||
},
|
||||
});
|
||||
|
||||
const virtualConversation =
|
||||
yield* CCSessionProcess.createVirtualConversation(sessionProcess, {
|
||||
sessionId: baseSessionId,
|
||||
userMessage: message,
|
||||
});
|
||||
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
sessionProcess.def.projectId,
|
||||
baseSessionId,
|
||||
[virtualConversation],
|
||||
);
|
||||
|
||||
sessionProcess.def.setNextMessage(message);
|
||||
return {
|
||||
sessionProcess,
|
||||
task,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const startTask = (options: {
|
||||
userConfig: UserConfig;
|
||||
baseSession: {
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
message: string;
|
||||
}) => {
|
||||
const { baseSession, message, userConfig } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setHooks: setMessageGeneratorHooks,
|
||||
} = createMessageGenerator();
|
||||
|
||||
const { sessionProcess, task } =
|
||||
yield* sessionProcessService.startSessionProcess({
|
||||
sessionDef: {
|
||||
projectId: baseSession.projectId,
|
||||
cwd: baseSession.cwd,
|
||||
abortController: new AbortController(),
|
||||
setNextMessage,
|
||||
sessionProcessId: ulid(),
|
||||
},
|
||||
taskDef:
|
||||
baseSession.sessionId === undefined
|
||||
? {
|
||||
type: "new",
|
||||
taskId: ulid(),
|
||||
}
|
||||
: {
|
||||
type: "resume",
|
||||
taskId: ulid(),
|
||||
sessionId: undefined,
|
||||
baseSessionId: baseSession.sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionInitializedPromise = controllablePromise<{
|
||||
sessionId: string;
|
||||
}>();
|
||||
const sessionFileCreatedPromise = controllablePromise<{
|
||||
sessionId: string;
|
||||
}>();
|
||||
|
||||
setMessageGeneratorHooks({
|
||||
onNewUserMessageResolved: async (message) => {
|
||||
Effect.runFork(
|
||||
sessionProcessService.toNotInitializedState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
rawUserMessage: message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMessage = (message: SDKMessage) =>
|
||||
Effect.gen(function* () {
|
||||
const processState = yield* sessionProcessService.getSessionProcess(
|
||||
sessionProcess.def.sessionProcessId,
|
||||
);
|
||||
|
||||
if (processState.type === "completed") {
|
||||
return "break" as const;
|
||||
}
|
||||
|
||||
if (processState.type === "paused") {
|
||||
// rule: paused は not_initialized に更新されてからくる想定
|
||||
yield* Effect.die(
|
||||
new Error("Illegal state: paused is not expected"),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "system" &&
|
||||
message.subtype === "init" &&
|
||||
processState.type === "not_initialized"
|
||||
) {
|
||||
yield* sessionProcessService.toInitializedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
initContext: {
|
||||
initMessage: message,
|
||||
},
|
||||
});
|
||||
|
||||
// Virtual Conversation Creation
|
||||
const virtualConversation =
|
||||
yield* CCSessionProcess.createVirtualConversation(processState, {
|
||||
sessionId: message.session_id,
|
||||
userMessage: processState.rawUserMessage,
|
||||
});
|
||||
|
||||
if (processState.currentTask.def.type === "new") {
|
||||
// 末尾に追加するだけで OK
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
baseSession.projectId,
|
||||
message.session_id,
|
||||
[virtualConversation],
|
||||
);
|
||||
} else if (processState.currentTask.def.type === "resume") {
|
||||
const existingSession = yield* sessionRepository.getSession(
|
||||
processState.def.projectId,
|
||||
processState.currentTask.def.baseSessionId,
|
||||
);
|
||||
|
||||
const copiedConversations =
|
||||
existingSession.session === null
|
||||
? []
|
||||
: existingSession.session.conversations;
|
||||
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
processState.def.projectId,
|
||||
message.session_id,
|
||||
[...copiedConversations, virtualConversation],
|
||||
);
|
||||
} else {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
sessionInitializedPromise.resolve({
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
yield* eventBusService.emit("sessionListChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
});
|
||||
|
||||
yield* eventBusService.emit("sessionChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
return "continue" as const;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
processState.type === "initialized"
|
||||
) {
|
||||
yield* sessionProcessService.toFileCreatedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
});
|
||||
|
||||
sessionFileCreatedPromise.resolve({
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
yield* virtualConversationDatabase.deleteVirtualConversations(
|
||||
message.session_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "result" &&
|
||||
processState.type === "file_created"
|
||||
) {
|
||||
yield* sessionProcessService.toPausedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
resultMessage: message,
|
||||
});
|
||||
|
||||
yield* eventBusService.emit("sessionChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
return "continue" as const;
|
||||
}
|
||||
|
||||
return "continue" as const;
|
||||
});
|
||||
|
||||
const handleSessionProcessDaemon = async () => {
|
||||
const messageIter = await Runtime.runPromise(runtime)(
|
||||
Effect.gen(function* () {
|
||||
const permissionOptions =
|
||||
yield* permissionService.createCanUseToolRelatedOptions({
|
||||
taskId: task.def.taskId,
|
||||
userConfig,
|
||||
sessionId: task.def.baseSessionId,
|
||||
});
|
||||
|
||||
return yield* ClaudeCode.query(generateMessages(), {
|
||||
resume: task.def.baseSessionId,
|
||||
cwd: sessionProcess.def.cwd,
|
||||
abortController: sessionProcess.def.abortController,
|
||||
...permissionOptions,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setNextMessage(message);
|
||||
|
||||
try {
|
||||
for await (const message of messageIter) {
|
||||
const result = await Runtime.runPromise(runtime)(
|
||||
handleMessage(message),
|
||||
).catch((error) => {
|
||||
// iter 自体が落ちてなければ継続したいので握りつぶす
|
||||
Effect.runFork(
|
||||
sessionProcessService.changeTaskState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
taskId: task.def.taskId,
|
||||
nextTask: {
|
||||
status: "failed",
|
||||
def: task.def,
|
||||
error: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return "continue" as const;
|
||||
});
|
||||
|
||||
if (result === "break") {
|
||||
break;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await Effect.runPromise(
|
||||
sessionProcessService.changeTaskState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
taskId: task.def.taskId,
|
||||
nextTask: {
|
||||
status: "failed",
|
||||
def: task.def,
|
||||
error: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const daemonPromise = handleSessionProcessDaemon()
|
||||
.catch((error) => {
|
||||
console.error("Error occur in task daemon process", error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
const currentProcess =
|
||||
yield* sessionProcessService.getSessionProcess(
|
||||
sessionProcess.def.sessionProcessId,
|
||||
);
|
||||
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: currentProcess.def.sessionProcessId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess,
|
||||
task,
|
||||
daemonPromise,
|
||||
awaitSessionInitialized: async () =>
|
||||
await sessionInitializedPromise.promise,
|
||||
awaitSessionFileCreated: async () =>
|
||||
await sessionFileCreatedPromise.promise,
|
||||
yieldSessionInitialized: () =>
|
||||
Effect.promise(() => sessionInitializedPromise.promise),
|
||||
yieldSessionFileCreated: () =>
|
||||
Effect.promise(() => sessionFileCreatedPromise.promise),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getPublicSessionProcesses = () =>
|
||||
Effect.gen(function* () {
|
||||
const processes = yield* sessionProcessService.getSessionProcesses();
|
||||
return processes.filter((process) => CCSessionProcess.isPublic(process));
|
||||
});
|
||||
|
||||
const abortTask = (sessionProcessId: string): Effect.Effect<void, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const currentProcess =
|
||||
yield* sessionProcessService.getSessionProcess(sessionProcessId);
|
||||
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: currentProcess.def.sessionProcessId,
|
||||
error: new Error("Task aborted"),
|
||||
});
|
||||
});
|
||||
|
||||
const abortAllTasks = () =>
|
||||
Effect.gen(function* () {
|
||||
const processes = yield* sessionProcessService.getSessionProcesses();
|
||||
|
||||
for (const process of processes) {
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: process.def.sessionProcessId,
|
||||
error: new Error("Task aborted"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
continueTask,
|
||||
startTask,
|
||||
abortTask,
|
||||
abortAllTasks,
|
||||
getPublicSessionProcesses,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeLifeCycleService = InferEffect<typeof LayerImpl>;
|
||||
|
||||
export class ClaudeCodeLifeCycleService extends Context.Tag(
|
||||
"ClaudeCodeLifeCycleService",
|
||||
)<ClaudeCodeLifeCycleService, IClaudeCodeLifeCycleService>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||