feat: resume and new task

This commit is contained in:
d-kimsuon
2025-09-01 11:10:04 +09:00
parent 299500c96d
commit 7c96a6316c
20 changed files with 1199 additions and 69 deletions

View File

@@ -22,7 +22,7 @@
"dev": "run-p 'dev:*'",
"dev:next": "PORT=3400 next dev --turbopack",
"start": "node dist/index.js",
"build": "next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
"build": "./scripts/build.sh",
"lint": "run-s 'lint:*'",
"lint:biome-format": "biome format .",
"lint:biome-lint": "biome check .",
@@ -34,6 +34,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.98",
"@hono/zod-validator": "^0.7.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",

121
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@anthropic-ai/claude-code':
specifier: ^1.0.98
version: 1.0.98
'@hono/zod-validator':
specifier: ^0.7.2
version: 0.7.2(hono@4.9.5)(zod@4.1.5)
@@ -127,6 +130,11 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@anthropic-ai/claude-code@1.0.98':
resolution: {integrity: sha512-IV193Eh8STdRcN3VkNcojPIlLnQPch+doBVrDSEV1rPPePISy7pzHFZL0Eg7zIPj9gHkHV1D2s0RMMwzVXJThA==}
engines: {node: '>=18.0.0'}
hasBin: true
'@babel/runtime@7.28.3':
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'}
@@ -376,33 +384,65 @@ packages:
hono: '>=3.9.0'
zod: ^3.25.0 || ^4.0.0
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-arm64@0.34.3':
resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.3':
resolution: {integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.0':
resolution: {integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.0':
resolution: {integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm64@1.2.0':
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
@@ -418,6 +458,11 @@ packages:
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
@@ -433,12 +478,24 @@ packages:
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -457,6 +514,12 @@ packages:
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -492,6 +555,12 @@ packages:
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@img/sharp-win32-x64@0.34.3':
resolution: {integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3132,6 +3201,15 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@anthropic-ai/claude-code@1.0.98':
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-arm64': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-win32-x64': 0.33.5
'@babel/runtime@7.28.3': {}
'@biomejs/biome@2.2.2':
@@ -3283,25 +3361,47 @@ snapshots:
hono: 4.9.5
zod: 4.1.5
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-arm64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.0
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.0
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.0':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.0':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.0':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-arm@1.2.0':
optional: true
@@ -3311,6 +3411,9 @@ snapshots:
'@img/sharp-libvips-linux-s390x@1.2.0':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.0':
optional: true
@@ -3320,11 +3423,21 @@ snapshots:
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.0
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-arm@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.0
@@ -3340,6 +3453,11 @@ snapshots:
'@img/sharp-libvips-linux-s390x': 1.2.0
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linux-x64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.0
@@ -3366,6 +3484,9 @@ snapshots:
'@img/sharp-win32-ia32@0.34.3':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@img/sharp-win32-x64@0.34.3':
optional: true

13
scripts/build.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euxo pipefail
if [ -d "dist/.next" ]; then
rm -rf dist/.next
fi
pnpm exec next build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
cp -r .next/standalone ./dist/

View File

@@ -2,12 +2,6 @@
set -euxo pipefail
if [ -d "dist/.next" ]; then
rm -rf dist/.next
fi
pnpm build
cp -r .next/standalone ./dist/
pnpm release-it

View File

@@ -1,7 +1,12 @@
"use client";
import { useAtom } from "jotai";
import { ArrowLeftIcon, FolderIcon, MessageSquareIcon } from "lucide-react";
import {
ArrowLeftIcon,
FolderIcon,
MessageSquareIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { useId } from "react";
import { Button } from "@/components/ui/button";
@@ -16,6 +21,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle";
import { hideSessionsWithoutUserMessagesAtom } from "../store/filterAtoms";
import { NewChatModal } from "./newChat/NewChatModal";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const checkboxId = useId();
@@ -40,11 +46,22 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
<FolderIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">
{project.meta.projectPath ?? project.claudeProjectPath}
</h1>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<FolderIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">
{project.meta.projectPath ?? project.claudeProjectPath}
</h1>
</div>
<NewChatModal
projectId={projectId}
trigger={
<Button size="lg" className="gap-2">
<PlusIcon className="w-5 h-5" />
Start New Chat
</Button>
}
/>
</div>
<p className="text-muted-foreground font-mono text-sm">
History File: {project.claudeProjectPath ?? "unknown"}
@@ -89,11 +106,20 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
<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">
<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>
) : (

View File

@@ -0,0 +1,240 @@
import { useQuery } from "@tanstack/react-query";
import { CheckIcon, TerminalIcon } from "lucide-react";
import type React from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "../../../../../components/ui/button";
import {
Collapsible,
CollapsibleContent,
} from "../../../../../components/ui/collapsible";
import { honoClient } from "../../../../../lib/api/client";
import { cn } from "../../../../../lib/utils";
type CommandCompletionProps = {
projectId: string;
inputValue: string;
onCommandSelect: (command: string) => void;
className?: string;
};
export type CommandCompletionRef = {
handleKeyDown: (e: React.KeyboardEvent) => boolean;
};
export const CommandCompletion = forwardRef<
CommandCompletionRef,
CommandCompletionProps
>(({ projectId, inputValue, onCommandSelect, className }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// コマンドリストを取得
const { data: commandData } = useQuery({
queryKey: ["claude-commands", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"][
"claude-commands"
].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error("Failed to fetch commands");
}
return response.json();
},
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
});
// メモ化されたコマンドフィルタリング
const { shouldShowCompletion, filteredCommands } = useMemo(() => {
const allCommands = [
...(commandData?.globalCommands || []),
...(commandData?.projectCommands || []),
];
const shouldShow = inputValue.startsWith("/");
const searchTerm = shouldShow ? inputValue.slice(1).toLowerCase() : "";
const filtered = shouldShow
? allCommands.filter((cmd) => cmd.toLowerCase().includes(searchTerm))
: [];
return { shouldShowCompletion: shouldShow, filteredCommands: filtered };
}, [commandData, inputValue]);
// 表示状態の導出useEffectを削除
const shouldBeOpen = shouldShowCompletion && filteredCommands.length > 0;
// 状態が変更された時のリセット処理
if (isOpen !== shouldBeOpen) {
setIsOpen(shouldBeOpen);
setSelectedIndex(-1);
}
// メモ化されたコマンド選択処理
const handleCommandSelect = useCallback(
(command: string) => {
onCommandSelect(`/${command} `);
setIsOpen(false);
setSelectedIndex(-1);
},
[onCommandSelect],
);
// スクロール処理
const scrollToSelected = useCallback(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[
selectedIndex + 1
] as HTMLElement; // +1 for header
if (selectedElement) {
selectedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [selectedIndex]);
// メモ化されたキーボードナビゲーション処理
const handleKeyboardNavigation = useCallback(
(e: React.KeyboardEvent): boolean => {
if (!isOpen || filteredCommands.length === 0) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredCommands.length - 1 ? prev + 1 : 0;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
return newIndex;
});
return true;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredCommands.length - 1;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
return newIndex;
});
return true;
case "Enter":
case "Tab":
if (selectedIndex >= 0 && selectedIndex < filteredCommands.length) {
e.preventDefault();
const selectedCommand = filteredCommands[selectedIndex];
if (selectedCommand) {
handleCommandSelect(selectedCommand);
}
return true;
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
return true;
}
return false;
},
[
isOpen,
filteredCommands.length,
selectedIndex,
handleCommandSelect,
scrollToSelected,
filteredCommands,
],
);
// 外部クリック処理をuseEffectで設定
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSelectedIndex(-1);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// useImperativeHandleでキーボードハンドラーを公開
useImperativeHandle(
ref,
() => ({
handleKeyDown: handleKeyboardNavigation,
}),
[handleKeyboardNavigation],
);
if (!shouldShowCompletion || filteredCommands.length === 0) {
return null;
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<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"
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",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
>
<span className="text-muted-foreground mr-1">/</span>
<span className="font-medium">{command}</span>
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
)}
</Button>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
});

View File

@@ -0,0 +1,135 @@
import { useMutation } from "@tanstack/react-query";
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
import { honoClient } from "../../../../../lib/api/client";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const startNewChat = useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"][
"new-session"
].$post({
param: { projectId },
json: { message: options.message },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: (response) => {
setMessage("");
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.nextSessionId}#message-${response.userMessageId}`,
);
},
});
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
startNewChat.mutate({ message: message.trim() });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
};
return (
<div className="space-y-4">
{startNewChat.error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to start new chat. Please try again.</span>
</div>
)}
<div className="space-y-3">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)"
className="min-h-[100px] resize-none"
disabled={startNewChat.isPending}
maxLength={4000}
aria-label="Message input with command completion"
aria-describedby={helpId}
aria-expanded={message.startsWith("/")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={completionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
</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
commands
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || startNewChat.isPending}
size="lg"
className="gap-2"
>
{startNewChat.isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Starting...
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Start Chat
</>
)}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { MessageSquareIcon, PlusIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../../../components/ui/dialog";
import { NewChat } from "./NewChat";
export const NewChatModal: FC<{
projectId: string;
trigger?: ReactNode;
}> = ({ projectId, trigger }) => {
const [open, setOpen] = useState(false);
const handleSuccess = () => {
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button className="gap-2">
<PlusIcon className="w-4 h-4" />
New Chat
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquareIcon className="w-5 h-5" />
Start New Chat
</DialogTitle>
<DialogDescription>
Start a new conversation with Claude Code for this project
</DialogDescription>
</DialogHeader>
<NewChat projectId={projectId} onSuccess={handleSuccess} />
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,15 @@
"use client";
import { ArrowLeftIcon, MessageSquareIcon } from "lucide-react";
import { ArrowLeftIcon, LoaderIcon } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useIsResummingTask } from "../hooks/useIsResummingTask";
import { useSession } from "../hooks/useSession";
import { ConversationList } from "./conversationList/ConversationList";
import { ResumeChat } from "./resumeChat/ResumeChat";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
export const SessionPageContent: FC<{
@@ -18,14 +21,36 @@ export const SessionPageContent: FC<{
sessionId,
);
const { isResummingTask } = useIsResummingTask(sessionId);
const [previouConversationLength, setPreviouConversationLength] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
useEffect(() => {
if (isResummingTask && conversations.length !== previouConversationLength) {
setPreviouConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
}
}
}, [conversations, isResummingTask, previouConversationLength]);
return (
<div className="flex h-screen">
<SessionSidebar currentSessionId={sessionId} projectId={projectId} />
<div className="flex-1 flex flex-col overflow-hidden">
<div className="max-w-none px-6 md:px-8 py-6 md:py-8 flex-1 overflow-y-auto">
<header className="mb-8">
<Button asChild variant="ghost" className="mb-4">
<div
ref={scrollContainerRef}
className="max-w-none flex-1 overflow-y-auto"
>
<header className="px-3 py-3 sticky top-0 z-10 bg-background w-full">
<Button asChild variant="ghost">
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-2"
@@ -35,24 +60,39 @@ export const SessionPageContent: FC<{
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
<MessageSquareIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold break-all overflow-ellipsis line-clamp-2">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: sessionId}
</h1>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold break-all overflow-ellipsis line-clamp-2 px-5">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: sessionId}
</h1>
</div>
{isResummingTask && (
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
<LoaderIcon className="w-4 h-4 animate-spin" />
<div className="flex-1">
<p className="text-sm font-medium">
Conversation is being resumed...
</p>
</div>
</div>
)}
<p className="text-muted-foreground font-mono text-sm">
Session ID: {sessionId}
</p>
</div>
<p className="text-muted-foreground font-mono text-sm">
Session ID: {sessionId}
</p>
</header>
<main className="w-full px-20">
<main className="w-full px-20 pb-20 relative z-5">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
<ResumeChat projectId={projectId} sessionId={sessionId} />
</main>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { ChevronDown, FileText, Lightbulb, Settings } from "lucide-react";
import { ChevronDown, Lightbulb, Settings } from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import {
@@ -112,7 +113,7 @@ export const AssistantConversationContent: FC<{
toolResult.content.map((item) => {
if (item.type === "image") {
return (
<img
<Image
key={item.source.data}
src={`data:${item.source.media_type};base64,${item.source.data}`}
alt="Tool Result"
@@ -143,29 +144,7 @@ export const AssistantConversationContent: FC<{
}
if (content.type === "tool_result") {
return (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20 gap-2 py-3">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<CardTitle className="text-sm font-medium">Tool Result</CardTitle>
<Badge
variant="outline"
className="border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300"
>
Debug
</Badge>
</div>
</CardHeader>
<CardContent className="py-0 px-4">
<div className="bg-background rounded border p-2">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(content, null, 2)}
</pre>
</div>
</CardContent>
</Card>
);
return null;
}
return null;

View File

@@ -64,7 +64,7 @@ export const ConversationItem: FC<{
typeof conversation.message.content === "string" ? (
<UserConversationContent content={conversation.message.content} />
) : (
<ul className="w-full">
<ul className="w-full" id={`message-${conversation.uuid}`}>
{conversation.message.content.map((content) => (
<li key={content.toString()}>
<UserConversationContent content={content} />

View File

@@ -10,17 +10,17 @@ import {
CardTitle,
} from "@/components/ui/card";
import type { UserMessageContent } from "@/lib/conversation-schema/message/UserMessageSchema";
import { TextContent } from "./TextContent";
import { UserTextContent } from "./UserTextContent";
export const UserConversationContent: FC<{
content: UserMessageContent;
}> = ({ content }) => {
if (typeof content === "string") {
return <TextContent text={content} />;
return <UserTextContent text={content} />;
}
if (content.type === "text") {
return <TextContent text={content.text} />;
return <UserTextContent text={content.text} />;
}
if (content.type === "image") {

View File

@@ -6,13 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { parseCommandXml } from "../../../../../../../server/service/parseCommandXml";
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
export const TextContent: FC<{ text: string }> = ({ text }) => {
export const UserTextContent: FC<{ text: string }> = ({ text }) => {
const parsed = parseCommandXml(text);
if (parsed.kind === "command" && parsed.commandName === "/init") {
console.log("debug /init", parsed);
}
if (parsed.kind === "command") {
return (
<Card className="border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20 gap-2 py-3 mb-3">
@@ -40,7 +36,9 @@ export const TextContent: FC<{ text: string }> = ({ text }) => {
Arguments:
</span>
<div className="bg-background rounded border p-2 mt-1">
<code className="text-xs">{parsed.commandArgs}</code>
<code className="text-xs whitespace-pre-line">
{parsed.commandArgs}
</code>
</div>
</>
)}
@@ -50,7 +48,9 @@ export const TextContent: FC<{ text: string }> = ({ text }) => {
Message:
</span>
<div className="bg-background rounded border p-2 mt-1">
<code className="text-xs">{parsed.commandMessage}</code>
<code className="text-xs whitespace-pre-line">
{parsed.commandMessage}
</code>
</div>
</>
)}
@@ -79,6 +79,9 @@ export const TextContent: FC<{ text: string }> = ({ text }) => {
}
return (
<MarkdownContent className="w-full mx-2 my-6" content={parsed.content} />
<MarkdownContent
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50"
content={parsed.content}
/>
);
};

View File

@@ -0,0 +1,158 @@
import { useMutation } from "@tanstack/react-query";
import {
AlertCircleIcon,
LoaderIcon,
MessageSquareIcon,
SendIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react";
import { Button } from "../../../../../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../../../../../components/ui/card";
import { Textarea } from "../../../../../../../components/ui/textarea";
import { honoClient } from "../../../../../../../lib/api/client";
import {
CommandCompletion,
type CommandCompletionRef,
} from "../../../../components/newChat/CommandCompletion";
export const ResumeChat: FC<{
projectId: string;
sessionId: string;
}> = ({ projectId, sessionId }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resumeChat = useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].resume.$post({
param: { projectId, sessionId },
json: { resumeMessage: options.message },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: (response) => {
setMessage("");
router.push(
`/projects/${projectId}/sessions/${response.nextSessionId}#message-${response.userMessageId}`,
);
},
});
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
resumeChat.mutate({ message: message.trim() });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
};
return (
<Card className="border-t rounded-t-none border-x-0 bg-background/50 backdrop-blur-sm mt-10">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<MessageSquareIcon className="w-5 h-5 text-primary" />
<CardTitle className="text-lg">Continue Conversation</CardTitle>
</div>
<CardDescription>
Start a new conversation based on this session's context
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{resumeChat.error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to resume chat. Please try again.</span>
</div>
)}
<div className="space-y-3">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)"
className="min-h-[80px] resize-none"
disabled={resumeChat.isPending}
maxLength={4000}
aria-label="Message input with command completion"
aria-describedby={helpId}
aria-expanded={message.startsWith("/")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={completionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
</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
commands
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || resumeChat.isPending}
size="lg"
className="gap-2"
>
{resumeChat.isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Starting...
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Resume Chat
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { MessageSquareIcon, PanelLeftIcon } from "lucide-react";
import { MessageSquareIcon, PanelLeftIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { type FC, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { Session } from "../../../../../../../server/service/types";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { useProject } from "../../../../hooks/useProject";
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
@@ -21,8 +22,19 @@ const SidebarContent: FC<{
}> = ({ sessions, currentSessionId, projectId }) => (
<div className="h-full flex flex-col bg-sidebar text-sidebar-foreground">
<div className="border-b border-sidebar-border p-4">
<h2 className="font-semibold text-lg">Sessions</h2>
<p className="text-xs text-sidebar-foreground/70 mt-1">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Sessions</h2>
<NewChatModal
projectId={projectId}
trigger={
<Button size="sm" variant="outline" className="gap-1.5 mr-5">
<PlusIcon className="w-3.5 h-3.5" />
New
</Button>
}
/>
</div>
<p className="text-xs text-sidebar-foreground/70">
{sessions.length} total
</p>
</div>

View File

@@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { honoClient } from "../../../../../../lib/api/client";
export const useIsResummingTask = (sessionId: string) => {
const { data, isLoading } = useQuery({
queryKey: ["runningTasks"],
queryFn: async () => {
const response = await honoClient.api.tasks.running.$get({});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
// Only poll when there might be running tasks
refetchInterval: (query) => {
const hasRunningTasks = (query.state.data?.runningTasks?.length ?? 0) > 0;
return hasRunningTasks ? 2000 : false; // Poll every 2s when there are tasks, stop when none
},
// Keep data fresh for 30 seconds
staleTime: 30 * 1000,
// Keep in cache for 5 minutes
gcTime: 5 * 60 * 1000,
// Refetch when window regains focus
refetchOnWindowFocus: true,
});
const taskInfo = useMemo(() => {
const runningTask = data?.runningTasks.find(
(task) => task.nextSessionId === sessionId,
);
return {
isResummingTask: Boolean(runningTask),
task: runningTask,
hasRunningTasks: (data?.runningTasks.length ?? 0) > 0,
};
}, [data, sessionId]);
return {
...taskInfo,
isLoading,
};
};

View File

@@ -0,0 +1,21 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,20 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[60px] w-full min-w-0 resize-none rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -1,4 +1,10 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { streamSSE } from "hono/streaming";
import { z } from "zod";
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEvent } from "../service/events/sseEvent";
import type { WatcherEvent } from "../service/events/types";
@@ -9,6 +15,8 @@ import { getSessions } from "../service/session/getSessions";
import type { HonoAppType } from "./app";
export const routes = (app: HonoAppType) => {
const taskController = new ClaudeCodeTaskController();
return app
.get("/projects", async (c) => {
const { projects } = await getProjects();
@@ -32,6 +40,102 @@ export const routes = (app: HonoAppType) => {
return c.json({ session });
})
.get("/projects/:projectId/claude-commands", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
const [globalCommands, projectCommands] = await Promise.allSettled([
readdir(resolve(homedir(), ".claude", "commands"), {
withFileTypes: true,
}).then((dirents) =>
dirents
.filter((d) => d.isFile() && d.name.endsWith(".md"))
.map((d) => d.name.replace(/\.md$/, "")),
),
project.meta.projectPath !== null
? readdir(resolve(project.meta.projectPath, ".claude", "commands"), {
withFileTypes: true,
}).then((dirents) =>
dirents
.filter((d) => d.isFile() && d.name.endsWith(".md"))
.map((d) => d.name.replace(/\.md$/, "")),
)
: [],
]);
return c.json({
globalCommands:
globalCommands.status === "fulfilled" ? globalCommands.value : [],
projectCommands:
projectCommands.status === "fulfilled" ? projectCommands.value : [],
});
})
.post(
"/projects/:projectId/new-session",
zValidator(
"json",
z.object({
message: z.string(),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { message } = c.req.valid("json");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
const task = await taskController.createTask({
projectId,
cwd: project.meta.projectPath,
message,
});
const { nextSessionId, userMessageId } = await taskController.startTask(
task.id,
);
return c.json({ taskId: task.id, nextSessionId, userMessageId });
},
)
.post(
"/projects/:projectId/sessions/:sessionId/resume",
zValidator(
"json",
z.object({
resumeMessage: z.string(),
}),
),
async (c) => {
const { projectId, sessionId } = c.req.param();
const { resumeMessage } = c.req.valid("json");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
const task = await taskController.createTask({
projectId,
sessionId,
cwd: project.meta.projectPath,
message: resumeMessage,
});
const { nextSessionId, userMessageId } = await taskController.startTask(
task.id,
);
return c.json({ taskId: task.id, nextSessionId, userMessageId });
},
)
.get("/tasks/running", async (c) => {
return c.json({ runningTasks: taskController.runningTasks });
})
.get("/events/state_changes", async (c) => {
return streamSSE(
c,

View File

@@ -0,0 +1,170 @@
import { execSync } from "node:child_process";
import { query, type SDKMessage } from "@anthropic-ai/claude-code";
import { ulid } from "ulid";
type OnMessage = (message: SDKMessage) => void | Promise<void>;
type BaseClaudeCodeTask = {
id: string;
projectId: string;
sessionId?: string | undefined; // undefined = new session
cwd: string;
message: string;
onMessageHandlers: OnMessage[];
};
type PendingClaudeCodeTask = BaseClaudeCodeTask & {
status: "pending";
};
type RunningClaudeCodeTask = BaseClaudeCodeTask & {
status: "running";
nextSessionId: string;
userMessageId: string;
};
type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
status: "completed";
nextSessionId: string;
userMessageId: string;
};
type FailedClaudeCodeTask = BaseClaudeCodeTask & {
status: "failed";
nextSessionId?: string;
userMessageId?: string;
};
type ClaudeCodeTask =
| PendingClaudeCodeTask
| RunningClaudeCodeTask
| CompletedClaudeCodeTask
| FailedClaudeCodeTask;
export class ClaudeCodeTaskController {
private pathToClaudeCodeExecutable: string;
private tasks: ClaudeCodeTask[] = [];
constructor() {
this.pathToClaudeCodeExecutable = execSync("which claude", {})
.toString()
.trim();
}
public async createTask(
taskDef: Omit<ClaudeCodeTask, "id" | "status" | "onMessageHandlers">,
onMessage?: OnMessage,
) {
const task: ClaudeCodeTask = {
...taskDef,
id: ulid(),
status: "pending",
onMessageHandlers: typeof onMessage === "function" ? [onMessage] : [],
};
this.tasks.push(task);
return task;
}
public get pendingTasks() {
return this.tasks.filter((task) => task.status === "pending");
}
public get runningTasks() {
return this.tasks.filter((task) => task.status === "running");
}
private updateExistingTask(task: ClaudeCodeTask) {
const target = this.tasks.find((t) => t.id === task.id);
if (!target) {
throw new Error("Task not found");
}
Object.assign(target, task);
}
public startTask(id: string) {
const task = this.tasks.find((task) => task.id === id);
if (!task) {
throw new Error("Task not found");
}
let runningTaskResolve: (task: RunningClaudeCodeTask) => void;
let runningTaskReject: (error: unknown) => void;
const runningTaskPromise = new Promise<RunningClaudeCodeTask>(
(resolve, reject) => {
runningTaskResolve = resolve;
runningTaskReject = reject;
},
);
let resolved = false;
const handleTask = async () => {
try {
for await (const message of query({
prompt: task.message,
options: {
resume: task.sessionId,
cwd: task.cwd,
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
permissionMode: "bypassPermissions",
},
})) {
// 初回の sysmte message だとまだ history ファイルが作成されていないので
if (
!resolved &&
(message.type === "user" || message.type === "assistant") &&
message.uuid !== undefined
) {
const runningTask: RunningClaudeCodeTask = {
...task,
status: "running",
nextSessionId: message.session_id,
userMessageId: message.uuid,
};
this.updateExistingTask(runningTask);
runningTaskResolve(runningTask);
resolved = true;
}
await Promise.all(
task.onMessageHandlers.map(async (onMessageHandler) => {
await onMessageHandler(message);
}),
);
}
if (task.status !== "running") {
const error = new Error(
`illegal state: task is not running, task: ${JSON.stringify(task)}`,
);
runningTaskReject(error);
throw error;
}
this.updateExistingTask({
...task,
status: "completed",
nextSessionId: task.nextSessionId,
userMessageId: task.userMessageId,
});
} catch (error) {
if (!resolved) {
runningTaskReject(error);
resolved = true;
}
console.error("Error resuming task", error);
task.status = "failed";
}
};
// continue background
void handleTask();
return runningTaskPromise;
}
}