Merge pull request #16 from d-kimuson/refactor/effect-ts

refactor: Introduce Effect-TS architecture and improve code organization
This commit is contained in:
きむそん
2025-10-18 04:30:45 +09:00
committed by GitHub
192 changed files with 13513 additions and 3234 deletions

22
.vscode/settings.json vendored
View File

@@ -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"
},

View File

@@ -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,
];

View File

@@ -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);
}
},
},
],
});

View File

@@ -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"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -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
View File

@@ -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

View File

@@ -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/

View File

@@ -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);

View File

@@ -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}
>

View File

@@ -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>
)}
>

View File

@@ -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}</>;

View 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
View 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>
);
}

View File

@@ -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],

View File

@@ -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
View 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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]);

View File

@@ -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";

View File

@@ -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}`,
);
}
},
});
};

View File

@@ -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"
/>
);
};

View 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>
);
}

View File

@@ -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,
});
};

View 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}`);
}

View 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>
);
}

View File

@@ -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`);
}

View File

@@ -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) {

View File

@@ -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>
</>
);
};

View File

@@ -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>

View File

@@ -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%]">

View File

@@ -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}
/>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 (

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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>
);

View File

@@ -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>
);
};

View 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>
);
}

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View 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;

View File

@@ -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>
);
}

View File

@@ -1,4 +0,0 @@
import { atom } from "jotai";
import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
export const aliveTasksAtom = atom<SerializableAliveTask[]>([]);

View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import type { PublicSessionProcess } from "../../../../../../types/session-process";
export const sessionProcessesAtom = atom<PublicSessionProcess[]>([]);

View 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>
);
};

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
};

View File

@@ -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>
);
};

View 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 };

View File

@@ -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,

View File

@@ -23,7 +23,6 @@ export const makeQueryClient = () =>
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60 * 5,
retry: false,
},
},

View File

@@ -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"]}/`
: "/",
);

View File

@@ -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;

View 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;
};

View File

@@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({
// required
message: UserMessageSchema,
});
export type UserEntry = z.infer<typeof UserEntrySchema>;

View File

@@ -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]);
};

View File

@@ -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);
});
});

View File

@@ -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, "-"),
);
});

View File

@@ -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,
};
};

View 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();
}
});
});
});

View File

@@ -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;
}

View File

@@ -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",
},
]);
});
});

View 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;
};

View 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 &amp; demo'</command-name>";
const result = parseUserMessage(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("git commit -m 'test &amp; 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");
}
});
});
});

View File

@@ -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)

View 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;
});
};

View 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);
});
});
});

View 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",
}),
},
});
});
};

View 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;

View 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);
});
});
});

View 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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More