mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-02-23 14:44:28 +01:00
feat: resume and new task
This commit is contained in:
@@ -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
121
pnpm-lock.yaml
generated
@@ -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
13
scripts/build.sh
Executable 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/
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
135
src/app/projects/[projectId]/components/newChat/NewChat.tsx
Normal file
135
src/app/projects/[projectId]/components/newChat/NewChat.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||
20
src/components/ui/textarea.tsx
Normal file
20
src/components/ui/textarea.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
170
src/server/service/claude-code/ClaudeCodeTaskController.ts
Normal file
170
src/server/service/claude-code/ClaudeCodeTaskController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user