Merge branch 'main' into fix/handle-missing-claude-directory

This commit is contained in:
d-kimsuon
2025-09-17 22:12:08 +09:00
66 changed files with 3906 additions and 546 deletions

45
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: CI
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Quality Checks
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
- name: Setup Node.js 20.12.0
uses: actions/setup-node@v4
with:
node-version: 20.12.0
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linting
run: pnpm lint
- name: Run type checking
run: pnpm typecheck
- name: Build project
run: pnpm build

View File

@@ -1 +1 @@
24.4.1
20.12.0

View File

@@ -1,5 +1,40 @@
# Changelog
## 0.3.1
###    Features
- Add configurable Enter key behavior for message input &nbsp;-&nbsp; by **nepula_h_okuyama** and **Claude** [<samp>(e37ca)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e37ca87)
### &nbsp;&nbsp;&nbsp;Bug Fixes
- Resolve lint and formatting errors &nbsp;-&nbsp; by **amay077** and **Claude** [<samp>(730d1)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/730d134)
##### &nbsp;&nbsp;&nbsp;&nbsp;[View changes on GitHub](https://github.com/d-kimuson/claude-code-viewer/compare/v0.3.0...0.3.1)
## 0.3.0
### &nbsp;&nbsp;&nbsp;Features
- Set timeout for new-chat & resume-chat &nbsp;-&nbsp; by **d-kimsuon** [<samp>(d0fda)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/d0fdade)
- Add @ file completion &nbsp;-&nbsp; by **d-kimsuon** [<samp>(60aaa)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/60aaae7)
- Inline completion for command and files &nbsp;-&nbsp; by **d-kimsuon** [<samp>(e90dc)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e90dc00)
- Fix out of style &nbsp;-&nbsp; by **d-kimsuon** [<samp>(7fafb)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/7fafb18)
- Add simple git diff preview modal &nbsp;-&nbsp; by **d-kimsuon** [<samp>(c5688)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/c568831)
- Add comprehensive CI workflow for quality checks &nbsp;-&nbsp; by **d-kimsuon** and **Claude** [<samp>(580e5)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/580e51f)
- Add notification when task paused &nbsp;-&nbsp; by **d-kimsuon** [<samp>(8b6b0)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/8b6b03b)
- Add sonner message on task completed &nbsp;-&nbsp; by **d-kimsuon** [<samp>(a3e6f)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/a3e6feb)
- **diff-view**: Display untacked added file &nbsp;-&nbsp; by **d-kimsuon** [<samp>(e7c3c)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e7c3c87)
### &nbsp;&nbsp;&nbsp;Bug Fixes
- Bug fix session list doesn't updated after filter config changed &nbsp;-&nbsp; by **d-kimsuon** [<samp>(52a23)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/52a231b)
- Fix header text content overflow &nbsp;-&nbsp; by **d-kimsuon** [<samp>(a618e)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/a618e24)
- Bug fix that input message gone out though new chat is not sent yet &nbsp;-&nbsp; by **d-kimsuon** [<samp>(ca316)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/ca31602)
- Add unsupported container property to schema &nbsp;-&nbsp; by **d-kimsuon** [<samp>(c7a1e)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/c7a1e6d)
##### &nbsp;&nbsp;&nbsp;&nbsp;[View changes on GitHub](https://github.com/d-kimuson/claude-code-viewer/compare/v0.2.4...0.3.0)
## 0.2.4
### &nbsp;&nbsp;&nbsp;Features

View File

@@ -1,6 +1,6 @@
{
"name": "@kimuson/claude-code-viewer",
"version": "0.2.4",
"version": "0.3.1",
"type": "module",
"license": "MIT",
"repository": {
@@ -44,6 +44,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.85.5",
@@ -53,6 +54,8 @@
"jotai": "^2.13.1",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
"parse-git-diff": "^0.0.19",
"prexit": "^2.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -60,6 +63,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"ulid": "^3.0.1",
"zod": "^4.1.5"

108
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.12)(react@19.1.1)
@@ -56,6 +59,12 @@ importers:
next:
specifier: 15.5.2
version: 15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
parse-git-diff:
specifier: ^0.0.19
version: 0.0.19
prexit:
specifier: ^2.3.0
version: 2.3.0
@@ -77,6 +86,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -842,6 +854,9 @@ packages:
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -1072,6 +1087,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -1175,6 +1203,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -2517,6 +2558,12 @@ packages:
resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.5.2:
resolution: {integrity: sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -2618,6 +2665,9 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-git-diff@0.0.19:
resolution: {integrity: sha512-oh3giwKzsPlOhekiDDyd/pfFKn04IZoTjEThquhfKigwiUHymiP/Tp6AN5nGIwXQdWuBTQvz9AaRdN5TBsJ8MA==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -2883,6 +2933,12 @@ packages:
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3800,6 +3856,8 @@ snapshots:
'@phun-ky/typeof@1.2.8': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
@@ -4026,6 +4084,35 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
aria-hidden: 1.2.6
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
@@ -4110,6 +4197,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/rect@1.1.1': {}
'@rollup/rollup-android-arm-eabi@4.49.0':
@@ -5567,6 +5663,11 @@ snapshots:
dependencies:
type-fest: 2.19.0
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
next@15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
'@next/env': 15.5.2
@@ -5714,6 +5815,8 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-git-diff@0.0.19: {}
parse-ms@4.0.0: {}
parse-path@7.1.0:
@@ -6078,6 +6181,11 @@ snapshots:
ip-address: 10.0.1
smart-buffer: 4.2.0
sonner@2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
source-map-js@1.2.1: {}
source-map-support@0.5.21:

View File

@@ -84,7 +84,10 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
},
p({ children, ...props }) {
return (
<p className="mb-4 leading-7 text-foreground" {...props}>
<p
className="mb-4 leading-7 text-foreground break-all"
{...props}
>
{children}
</p>
);
@@ -117,7 +120,7 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
if (isInline) {
return (
<code
className="bg-muted/70 px-2 py-1 rounded-md text-sm font-mono text-foreground border"
className="bg-muted/70 px-2 py-1 rounded-md text-sm font-mono text-foreground border break-all"
{...props}
>
{children}
@@ -175,8 +178,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
// テーブルの改善
table({ children, ...props }) {
return (
<div className="overflow-x-auto my-6 rounded-lg border border-border">
<table className="min-w-full border-collapse" {...props}>
<div className="overflow-x-auto my-6 rounded-lg border border-border max-w-full">
<table className="w-full border-collapse" {...props}>
{children}
</table>
</div>

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import { ServerEventsProvider } from "./components/ServerEventsProvider";
@@ -47,6 +48,7 @@ export default async function RootLayout({
<ServerEventsProvider>{children}</ServerEventsProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>
<Toaster position="top-right" />
</body>
</html>
);

View File

@@ -45,12 +45,6 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
});
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
const handleConfigChange = () => {
void queryClient.invalidateQueries({
queryKey: projectQueryConfig(projectId).queryKey,
});
};
return (
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8 max-w-6xl">
<header className="mb-6 sm:mb-8">
@@ -118,7 +112,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 bg-muted/50 rounded-lg border">
<SettingsControls onConfigChange={handleConfigChange} />
<SettingsControls openingProjectId={projectId} />
</div>
</CollapsibleContent>
</div>

View File

@@ -0,0 +1,226 @@
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { type FC, useCallback, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
import { useConfig } from "../../../../hooks/useConfig";
import type { CommandCompletionRef } from "./CommandCompletion";
import type { FileCompletionRef } from "./FileCompletion";
import { InlineCompletion } from "./InlineCompletion";
export interface ChatInputProps {
projectId: string;
onSubmit: (message: string) => Promise<void>;
isPending: boolean;
error?: Error | null;
placeholder: string;
buttonText: string;
minHeight?: string;
containerClassName?: string;
disabled?: boolean;
buttonSize?: "sm" | "default" | "lg";
}
export const ChatInput: FC<ChatInputProps> = ({
projectId,
onSubmit,
isPending,
error,
placeholder,
buttonText,
minHeight = "min-h-[100px]",
containerClassName = "",
disabled = false,
buttonSize = "lg",
}) => {
const [message, setMessage] = useState("");
const [cursorPosition, setCursorPosition] = useState<{
relative: { top: number; left: number };
absolute: { top: number; left: number };
}>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } });
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const commandCompletionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
const { config } = useConfig();
const handleSubmit = async () => {
if (!message.trim()) return;
await onSubmit(message.trim());
setMessage("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (fileCompletionRef.current?.handleKeyDown(e)) {
return;
}
if (commandCompletionRef.current?.handleKeyDown(e)) {
return;
}
// IMEで変換中の場合は送信しない
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend && !e.shiftKey) {
// Enter: Send mode
e.preventDefault();
handleSubmit();
} else if (!isEnterSend && e.shiftKey) {
// Shift+Enter: Send mode (default)
e.preventDefault();
handleSubmit();
}
}
};
const getCursorPosition = useCallback(() => {
const textarea = textareaRef.current;
const container = containerRef.current;
if (textarea === null || container === null) return undefined;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = textarea.value.substring(0, cursorPos);
const textAfterCursor = textarea.value.substring(cursorPos);
const pre = document.createTextNode(textBeforeCursor);
const post = document.createTextNode(textAfterCursor);
const caret = document.createElement("span");
caret.innerHTML = "&nbsp;";
const mirrored = document.createElement("div");
mirrored.innerHTML = "";
mirrored.append(pre, caret, post);
const textareaStyles = window.getComputedStyle(textarea);
for (const property of [
"border",
"boxSizing",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"padding",
"textDecoration",
"textIndent",
"textTransform",
"whiteSpace",
"wordSpacing",
"wordWrap",
] as const) {
mirrored.style[property] = textareaStyles[property];
}
mirrored.style.visibility = "hidden";
container.prepend(mirrored);
const caretRect = caret.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
container.removeChild(mirrored);
return {
relative: {
top: caretRect.top - containerRect.top - textarea.scrollTop,
left: caretRect.left - containerRect.left - textarea.scrollLeft,
},
absolute: {
top: caretRect.top - textarea.scrollTop,
left: caretRect.left - textarea.scrollLeft,
},
};
}, []);
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
setMessage(filePath);
textareaRef.current?.focus();
};
return (
<div className={containerClassName}>
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md mb-4">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to send message. Please try again.</span>
</div>
)}
<div className="space-y-3">
<div className="relative" ref={containerRef}>
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => {
if (
e.target.value.endsWith("@") ||
e.target.value.endsWith("/")
) {
const position = getCursorPosition();
if (position) {
setCursorPosition(position);
}
}
setMessage(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${minHeight} resize-none`}
disabled={isPending || disabled}
maxLength={4000}
aria-label="Message input with completion support"
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<InlineCompletion
projectId={projectId}
message={message}
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
cursorPosition={cursorPosition}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000 characters " • Use arrow keys to navigate
completions"
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled}
size={buttonSize}
className="gap-2"
>
{isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : (
<>
<SendIcon className="w-4 h-4" />
{buttonText}
</>
)}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -93,19 +93,19 @@ export const CommandCompletion = forwardRef<
);
// スクロール処理
const scrollToSelected = useCallback(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[
selectedIndex + 1
] as HTMLElement; // +1 for header
if (selectedElement) {
selectedElement.scrollIntoView({
const scrollToSelected = useCallback((index: number) => {
if (index >= 0 && listRef.current) {
// ボタン要素を直接検索
const buttons = listRef.current.querySelectorAll('button[role="option"]');
const selectedButton = buttons[index] as HTMLElement;
if (selectedButton) {
selectedButton.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [selectedIndex]);
}, []);
// メモ化されたキーボードナビゲーション処理
const handleKeyboardNavigation = useCallback(
@@ -117,8 +117,8 @@ export const CommandCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredCommands.length - 1 ? prev + 1 : 0;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -126,8 +126,8 @@ export const CommandCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredCommands.length - 1;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -214,7 +214,7 @@ export const CommandCompletion = forwardRef<
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2",
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
@@ -223,11 +223,16 @@ export const CommandCompletion = forwardRef<
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
>
<span className="text-muted-foreground mr-1">/</span>
<span className="font-medium">{command}</span>
<span className="text-muted-foreground mr-1 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}

View File

@@ -0,0 +1,326 @@
import { CheckIcon, FileIcon, FolderIcon } 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 {
type FileCompletionEntry,
useFileCompletion,
} from "../../../../../hooks/useFileCompletion";
import { cn } from "../../../../../lib/utils";
type FileCompletionProps = {
projectId: string;
inputValue: string;
onFileSelect: (filePath: string) => void;
className?: string;
};
export type FileCompletionRef = {
handleKeyDown: (e: React.KeyboardEvent) => boolean;
};
// Parse the @ completion from input value
const parseFileCompletionFromInput = (input: string) => {
// Find the last @ symbol
const lastAtIndex = input.lastIndexOf("@");
if (lastAtIndex === -1) {
return { shouldShow: false, searchPath: "", beforeAt: "", afterAt: "" };
}
// Get the text before and after @
const beforeAt = input.slice(0, lastAtIndex);
const afterAt = input.slice(lastAtIndex + 1);
// Check if we're in the middle of a word after @ (no space after the path)
const parts = afterAt.split(/\s/);
const searchPath = parts[0] || "";
// Don't show completion if there's a space after the path (user has finished typing the path)
// This includes cases like "@hoge " where parts = ["hoge", ""]
const hasSpaceAfterPath = parts.length > 1;
return {
shouldShow: !hasSpaceAfterPath,
searchPath,
beforeAt,
afterAt,
};
};
export const FileCompletion = forwardRef<
FileCompletionRef,
FileCompletionProps
>(({ projectId, inputValue, onFileSelect, className }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Parse the input to extract the path being completed
const { shouldShow, searchPath, beforeAt, afterAt } = useMemo(
() => parseFileCompletionFromInput(inputValue),
[inputValue],
);
// Determine the base path and filter term
const { basePath, filterTerm } = useMemo(() => {
if (!searchPath) {
return { basePath: "/", filterTerm: "" };
}
const lastSlashIndex = searchPath.lastIndexOf("/");
if (lastSlashIndex === -1) {
return { basePath: "/", filterTerm: searchPath };
}
const path = searchPath.slice(0, lastSlashIndex + 1);
const term = searchPath.slice(lastSlashIndex + 1);
return {
basePath: path === "/" ? "/" : path,
filterTerm: term,
};
}, [searchPath]);
// Fetch file completion data
const { data: completionData, isLoading } = useFileCompletion(
projectId,
basePath,
shouldShow,
);
// Filter entries based on the current filter term
const filteredEntries = useMemo(() => {
if (!completionData?.entries) return [];
if (!filterTerm) {
return completionData.entries;
}
return completionData.entries.filter((entry) =>
entry.name.toLowerCase().includes(filterTerm.toLowerCase()),
);
}, [completionData?.entries, filterTerm]);
// Determine if completion should be shown
const shouldBeOpen = shouldShow && !isLoading && filteredEntries.length > 0;
// Update open state when it should change
if (isOpen !== shouldBeOpen) {
setIsOpen(shouldBeOpen);
setSelectedIndex(-1);
}
// Handle file/directory selection with different behaviors for different triggers
const handleEntrySelect = useCallback(
(entry: FileCompletionEntry, forceClose = false) => {
const fullPath = entry.path;
// For directories, add a trailing slash to continue completion (unless forced to close)
// For files or when forced to close, add a space to end completion
// Reconstruct the message with the selected path
const remainingText = afterAt.split(/\s/).slice(1).join(" ");
const newMessage =
`${beforeAt}@${fullPath}${remainingText}`.trim() +
(entry.type === "directory" && !forceClose ? "/" : " ");
onFileSelect(newMessage);
// Close completion if it's a file, or if forced to close
if (entry.type === "file" || forceClose) {
setIsOpen(false);
setSelectedIndex(-1);
}
},
[beforeAt, afterAt, onFileSelect],
);
// Scroll to selected entry
const scrollToSelected = useCallback((index: number) => {
if (index >= 0 && listRef.current) {
// ボタン要素を直接検索
const buttons = listRef.current.querySelectorAll('button[role="option"]');
const selectedButton = buttons[index] as HTMLElement;
if (selectedButton) {
selectedButton.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, []);
// Keyboard navigation
const handleKeyboardNavigation = useCallback(
(e: React.KeyboardEvent): boolean => {
if (!isOpen || filteredEntries.length === 0) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0;
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1;
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
case "Enter":
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
e.preventDefault();
const selectedEntry = filteredEntries[selectedIndex];
if (selectedEntry) {
// Enter always closes completion (even for directories)
handleEntrySelect(selectedEntry, true);
}
return true;
}
break;
case "Tab":
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
e.preventDefault();
const selectedEntry = filteredEntries[selectedIndex];
if (selectedEntry) {
// Tab: continue completion for directories, close for files
handleEntrySelect(selectedEntry, selectedEntry.type === "file");
}
return true;
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
return true;
}
return false;
},
[
isOpen,
filteredEntries.length,
selectedIndex,
handleEntrySelect,
scrollToSelected,
filteredEntries,
],
);
// Handle clicks outside the component
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);
}, []);
// Expose keyboard handler to parent
useImperativeHandle(
ref,
() => ({
handleKeyDown: handleKeyboardNavigation,
}),
[handleKeyboardNavigation],
);
if (!shouldShow || isLoading || filteredEntries.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 files and directories"
>
{filteredEntries.length > 0 && (
<div className="p-1">
<div
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
role="presentation"
>
<FileIcon className="w-3 h-3" />
Files & Directories ({filteredEntries.length})
{basePath !== "/" && (
<span className="text-xs font-mono text-muted-foreground/70">
in {basePath}
</span>
)}
</div>
{filteredEntries.map((entry, index) => (
<Button
key={entry.path}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
>
{entry.type === "directory" ? (
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" />
) : (
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" />
)}
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
});
FileCompletion.displayName = "FileCompletion";

View File

@@ -0,0 +1,131 @@
import type { FC, RefObject } from "react";
import { useMemo } from "react";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
interface PositionStyle {
top: number;
left: number;
placement: "above" | "below";
}
const calculateOptimalPosition = (
relativeCursorPosition: { top: number; left: number },
absoluteCursorPosition: { top: number; left: number },
): PositionStyle => {
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 800;
const viewportCenter = viewportHeight / 2;
// Estimated completion height (we'll measure actual height later if needed)
const estimatedCompletionHeight = 200;
// Determine preferred placement based on viewport position
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
// Check if there's enough space for preferred placement
const spaceBelow = viewportHeight - absoluteCursorPosition.top;
const spaceAbove = absoluteCursorPosition.top;
let placement: "above" | "below";
let top: number;
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) {
// Cursor in upper half and enough space below - place below
placement = "below";
top = relativeCursorPosition.top + 16;
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) {
// Cursor in lower half and enough space above - place above
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
} else {
// Use whichever side has more space
if (spaceBelow > spaceAbove) {
placement = "below";
top = relativeCursorPosition.top + 16;
} else {
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
}
}
// Ensure left position stays within viewport bounds
const estimatedCompletionWidth = 512; // Current w-lg width
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1200;
const maxLeft = viewportWidth - estimatedCompletionWidth - 16;
const adjustedLeft = Math.max(
16,
Math.min(relativeCursorPosition.left - 16, maxLeft),
);
return {
top,
left: adjustedLeft,
placement,
};
};
export const InlineCompletion: FC<{
projectId: string;
message: string;
commandCompletionRef: RefObject<CommandCompletionRef | null>;
fileCompletionRef: RefObject<FileCompletionRef | null>;
handleCommandSelect: (command: string) => void;
handleFileSelect: (filePath: string) => void;
cursorPosition: {
relative: { top: number; left: number };
absolute: { top: number; left: number };
};
}> = ({
projectId,
message,
commandCompletionRef,
fileCompletionRef,
handleCommandSelect,
handleFileSelect,
cursorPosition,
}) => {
const position = useMemo(() => {
return calculateOptimalPosition(
cursorPosition.relative,
cursorPosition.absolute,
);
}, [cursorPosition]);
return (
<div
className="absolute w-full max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl"
style={{
top: position.top,
left: position.left,
maxWidth:
typeof window !== "undefined"
? Math.min(512, window.innerWidth * 0.8)
: 512,
}}
>
<CommandCompletion
ref={commandCompletionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className={`absolute left-0 right-0 ${
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
}`}
/>
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className={`absolute left-0 right-0 ${
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
}`}
/>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export type { ChatInputProps } from "./ChatInput";
export { ChatInput } from "./ChatInput";
export type { CommandCompletionRef } from "./CommandCompletion";
export { CommandCompletion } from "./CommandCompletion";
export type { FileCompletionRef } from "./FileCompletion";
export { FileCompletion } from "./FileCompletion";
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";

View File

@@ -0,0 +1,75 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { honoClient } from "../../../../../lib/api/client";
export const useNewChatMutation = (
projectId: string,
onSuccess?: () => void,
) => {
const router = useRouter();
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"][
"new-session"
].$post(
{
param: { projectId },
json: { message: options.message },
},
{
init: {
signal: AbortSignal.timeout(20 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
},
});
};
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
const router = useRouter();
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].resume.$post(
{
param: { projectId, sessionId },
json: { resumeMessage: options.message },
},
{
init: {
signal: AbortSignal.timeout(20 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
if (sessionId !== response.sessionId) {
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
}
},
});
};

View File

@@ -1,136 +1,36 @@
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";
import type { FC } from "react";
import { useConfig } from "../../../../hooks/useConfig";
import { ChatInput, useNewChatMutation } from "../chatForm";
export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const startNewChat = useNewChatMutation(projectId, onSuccess);
const { config } = useConfig();
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: async (response) => {
setMessage("");
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#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 handleSubmit = async (message: string) => {
await startNewChat.mutateAsync({ message });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
};
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" />
Sending... This may take a while.
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Start Chat
</>
)}
</Button>
</div>
</div>
</div>
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={startNewChat.isPending}
error={startNewChat.error}
placeholder={getPlaceholder()}
buttonText="Start Chat"
minHeight="min-h-[200px]"
containerClassName="space-y-4"
/>
);
};

View File

@@ -4,7 +4,6 @@ import { Button } from "../../../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -31,15 +30,12 @@ export const NewChatModal: FC<{
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="w-[95vw] md:w-[80vw]">
<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>

View File

@@ -2,7 +2,8 @@
import { useMutation } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ExternalLinkIcon,
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
@@ -12,12 +13,15 @@ import Link from "next/link";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useAliveTask } from "../hooks/useAliveTask";
import { useSession } from "../hooks/useSession";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ResumeChat } from "./resumeChat/ResumeChat";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
@@ -47,9 +51,13 @@ export const SessionPageContent: FC<{
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
// Set up task completion notifications
useTaskNotifications(isRunningTask);
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
@@ -70,7 +78,7 @@ export const SessionPageContent: FC<{
}, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
return (
<div className="flex h-screen max-h-screen">
<div className="flex h-screen max-h-screen overflow-hidden">
<SessionSidebar
currentSessionId={sessionId}
projectId={projectId}
@@ -78,57 +86,55 @@ export const SessionPageContent: FC<{
onMobileOpenChange={setIsMobileSidebarOpen}
/>
<div className="flex-1 flex flex-col min-h-0">
<header className="px-2 sm:px-3 py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0">
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="sm"
className="md:hidden"
onClick={() => setIsMobileSidebarOpen(true)}
>
<MenuIcon className="w-4 h-4" />
</Button>
<Button asChild variant="ghost">
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-2"
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
>
<ArrowLeftIcon className="w-4 h-4" />
<span className="hidden sm:inline">Back to Session List</span>
<span className="sm:hidden">Back</span>
</Link>
</Button>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold break-words overflow-ellipsis line-clamp-1 px-2 sm:px-5">
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: sessionId}
</h1>
</div>
<div className="px-2 sm:px-5 space-y-1">
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.project.claudeProjectPath && (
<p className="text-sm text-muted-foreground font-mono break-all">
Project:{" "}
{project.project.meta.projectPath ??
project.project.claudeProjectPath}
</p>
<Link
href={`/projects/${projectId}`}
target="_blank"
className="transition-all duration-200"
>
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
>
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
{project.project.meta.projectPath ??
project.project.claudeProjectPath}
</Badge>
</Link>
)}
<p className="text-sm text-muted-foreground font-mono">
Session ID: {sessionId}
</p>
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{sessionId}
</Badge>
</div>
{isRunningTask && (
<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 items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<div className="flex-1">
<p className="text-sm font-medium">
<p className="text-xs sm:text-sm font-medium">
Conversation is in progress...
</p>
</div>
@@ -139,17 +145,17 @@ export const SessionPageContent: FC<{
abortTask.mutate(sessionId);
}}
>
<XIcon className="w-4 h-4" />
Abort
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Abort</span>
</Button>
</div>
)}
{isPausedTask && (
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
<PauseIcon className="w-4 h-4" />
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<div className="flex-1">
<p className="text-sm font-medium">
<p className="text-xs sm:text-sm font-medium">
Conversation is paused...
</p>
</div>
@@ -160,8 +166,8 @@ export const SessionPageContent: FC<{
abortTask.mutate(sessionId);
}}
>
<XIcon className="w-4 h-4" />
Abort
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Abort</span>
</Button>
</div>
)}
@@ -170,14 +176,31 @@ export const SessionPageContent: FC<{
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
className="flex-1 overflow-y-auto min-h-0 min-w-0"
>
<main className="w-full px-4 sm:px-8 md:px-12 lg:px-16 xl:px-20 pb-20 sm:pb-10 relative z-5">
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
{isRunningTask && (
<div className="flex justify-start items-center py-8">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
</div>
</div>
<p className="text-sm text-muted-foreground font-medium">
Claude Code is processing...
</p>
</div>
</div>
)}
<ResumeChat
projectId={projectId}
sessionId={sessionId}
@@ -187,6 +210,22 @@ export const SessionPageContent: FC<{
</main>
</div>
</div>
{/* Fixed Diff Button */}
<Button
onClick={() => setIsDiffModalOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
size="lg"
>
<GitCompareIcon className="w-6 h-6" />
</Button>
{/* Diff Modal */}
<DiffModal
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
</div>
);
};

View File

@@ -37,7 +37,7 @@ const getConversationKey = (conversation: Conversation) => {
const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
return (
<li className="w-full flex justify-start">
<div className="w-full max-w-4xl sm:w-[90%] md:w-[85%] px-2">
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%] px-2">
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 border-l-2 border-red-400">
@@ -146,7 +146,9 @@ export const ConversationList: FC<ConversationListProps> = ({
}`}
key={getConversationKey(conversation)}
>
<div className="w-full max-w-4xl sm:w-[90%] md:w-[85%]">{elm}</div>
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]">
{elm}
</div>
</li>,
];
})}

View File

@@ -12,7 +12,7 @@ export const MetaConversationContent: FC<PropsWithChildren> = ({
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 mb-2">
<h4 className="text-xs font-medium text-muted-foreground">
Meta Information
</h4>

View File

@@ -12,7 +12,7 @@ export const SummaryConversationContent: FC<PropsWithChildren> = ({
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 mb-2">
<h4 className="text-xs font-medium text-muted-foreground">
Summarized
</h4>

View File

@@ -42,7 +42,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
Arguments:
</span>
<div className="bg-background rounded border p-2 mt-1">
<code className="text-xs whitespace-pre-line">
<code className="text-xs whitespace-pre-line break-all">
{parsed.commandArgs}
</code>
</div>
@@ -54,7 +54,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
Message:
</span>
<div className="bg-background rounded border p-2 mt-1">
<code className="text-xs whitespace-pre-line">
<code className="text-xs whitespace-pre-line break-all">
{parsed.commandMessage}
</code>
</div>

View File

@@ -76,7 +76,7 @@ export const SidechainConversationModal: FC<
</div>
</Button>
</DialogTrigger>
<DialogContent className="!w-[1200px] !max-w-none max-h-[80vh] overflow-hidden flex flex-col">
<DialogContent className="w-[95vw] md:w-[90vw] max-h-[80vh] overflow-hidden flex flex-col px-2 md:px-8">
<DialogHeader>
<DialogTitle>
{title.length > 100 ? `${title.slice(0, 100)}...` : title}

View File

@@ -0,0 +1,304 @@
"use client";
import { FileText, GitBranch, Loader2, RefreshCcwIcon } from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useGitBranches, useGitCommits, useGitDiff } from "../../hooks/useGit";
import { DiffViewer } from "./DiffViewer";
import type { DiffModalProps, DiffSummary, GitRef } from "./types";
interface DiffSummaryProps {
summary: DiffSummary;
className?: string;
}
const DiffSummaryComponent: FC<DiffSummaryProps> = ({ summary, className }) => {
return (
<div
className={cn(
"bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700",
className,
)}
>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<span className="font-medium">
<span className="hidden sm:inline">
{summary.filesChanged} files changed
</span>
<span className="sm:hidden">{summary.filesChanged} files</span>
</span>
</div>
<div className="flex items-center gap-3">
{summary.insertions > 0 && (
<span className="text-green-600 dark:text-green-400 font-medium">
+{summary.insertions}
</span>
)}
{summary.deletions > 0 && (
<span className="text-red-600 dark:text-red-400 font-medium">
-{summary.deletions}
</span>
)}
</div>
</div>
</div>
);
};
interface RefSelectorProps {
label: string;
value: string;
onValueChange: (value: GitRef["name"]) => void;
refs: GitRef[];
}
const RefSelector: FC<RefSelectorProps> = ({
label,
value,
onValueChange,
refs,
}) => {
const id = useId();
const getRefIcon = (type: GitRef["type"]) => {
switch (type) {
case "branch":
return <GitBranch className="h-4 w-4" />;
case "commit":
return <span className="text-xs">📝</span>;
case "working":
return <span className="text-xs">🚧</span>;
default:
return <GitBranch className="h-4 w-4" />;
}
};
return (
<div className="space-y-2">
<label
htmlFor={id}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent id={id}>
{refs.map((ref) => (
<SelectItem key={ref.name} value={ref.name}>
<div className="flex items-center gap-2">
{getRefIcon(ref.type)}
<span>{ref.displayName}</span>
{ref.sha && (
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{ref.sha.substring(0, 7)}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
export const DiffModal: FC<DiffModalProps> = ({
isOpen,
onOpenChange,
projectId,
defaultCompareFrom = "HEAD",
defaultCompareTo = "working",
}) => {
const [compareFrom, setCompareFrom] = useState(defaultCompareFrom);
const [compareTo, setCompareTo] = useState(defaultCompareTo);
// API hooks
const { data: branchesData, isLoading: isLoadingBranches } =
useGitBranches(projectId);
const { data: commitsData, isLoading: isLoadingCommits } =
useGitCommits(projectId);
const {
mutate: getDiff,
data: diffData,
isPending: isDiffLoading,
error: diffError,
} = useGitDiff();
// Transform branches and commits data to GitRef format
const gitRefs: GitRef[] =
branchesData?.success && branchesData.data
? [
{
name: "working" as const,
type: "working" as const,
displayName: "Uncommitted changes",
},
{
name: "HEAD" as const,
type: "commit" as const,
displayName: "HEAD",
},
...branchesData.data.map((branch) => ({
name: `branch:${branch.name}` as const,
type: "branch" as const,
displayName: branch.name + (branch.current ? " (current)" : ""),
sha: branch.commit,
})),
// Add commits from current branch
...(commitsData?.success && commitsData.data
? commitsData.data.map((commit) => ({
name: `commit:${commit.sha}` as const,
type: "commit" as const,
displayName: `${commit.message.substring(0, 50)}${commit.message.length > 50 ? "..." : ""}`,
sha: commit.sha,
}))
: []),
]
: [];
const loadDiff = useCallback(() => {
if (compareFrom && compareTo && compareFrom !== compareTo) {
getDiff({
projectId,
fromRef: compareFrom,
toRef: compareTo,
});
}
}, [compareFrom, compareTo, getDiff, projectId]);
useEffect(() => {
if (isOpen && compareFrom && compareTo) {
loadDiff();
}
}, [isOpen, compareFrom, compareTo, loadDiff]);
const handleCompare = () => {
loadDiff();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] overflow-hidden flex flex-col px-2 md:px-8">
<DialogHeader>
<DialogTitle>Preview Changes</DialogTitle>
</DialogHeader>
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex flex-col sm:flex-row gap-3 flex-1">
<RefSelector
label="Compare from"
value={compareFrom}
onValueChange={setCompareFrom}
refs={gitRefs.filter((ref) => ref.name !== "working")}
/>
<RefSelector
label="Compare to"
value={compareTo}
onValueChange={setCompareTo}
refs={gitRefs}
/>
</div>
<Button
onClick={handleCompare}
disabled={
isDiffLoading ||
isLoadingBranches ||
isLoadingCommits ||
compareFrom === compareTo
}
className="sm:self-end w-full sm:w-auto"
>
{isDiffLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Loading...
</>
) : (
<RefreshCcwIcon className="w-4 h-4" />
)}
</Button>
</div>
{diffError && (
<div className="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400 text-sm">
{diffError.message}
</p>
</div>
)}
{diffData?.success && (
<>
<DiffSummaryComponent
summary={{
filesChanged: diffData.data.files.length,
insertions: diffData.data.summary.totalAdditions,
deletions: diffData.data.summary.totalDeletions,
files: diffData.data.diffs.map((diff) => ({
filename: diff.file.filePath,
oldFilename: diff.file.oldPath,
isNew: diff.file.status === "added",
isDeleted: diff.file.status === "deleted",
isRenamed: diff.file.status === "renamed",
isBinary: false,
hunks: diff.hunks,
linesAdded: diff.file.additions,
linesDeleted: diff.file.deletions,
})),
}}
/>
<div className="flex-1 overflow-auto space-y-6">
{diffData.data.diffs.map((diff) => (
<DiffViewer
key={diff.file.filePath}
fileDiff={{
filename: diff.file.filePath,
oldFilename: diff.file.oldPath,
isNew: diff.file.status === "added",
isDeleted: diff.file.status === "deleted",
isRenamed: diff.file.status === "renamed",
isBinary: false,
hunks: diff.hunks,
linesAdded: diff.file.additions,
linesDeleted: diff.file.deletions,
}}
/>
))}
</div>
</>
)}
{isDiffLoading && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-2">
<Loader2 className="w-8 h-8 animate-spin mx-auto" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Loading diff...
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,254 @@
"use client";
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "../../../../../../../components/ui/button";
import type { DiffHunk, FileDiff } from "./types";
interface DiffViewerProps {
fileDiff: FileDiff;
className?: string;
}
interface DiffHunkProps {
hunk: DiffHunk;
}
const DiffHunkComponent: FC<DiffHunkProps> = ({ hunk }) => {
return (
<div className="relative flex overflow-x-auto">
{/* 行番号列(固定) */}
<div className="flex-shrink-0 sticky left-0 z-10 bg-white dark:bg-gray-900">
{/* 旧行番号列 */}
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
{hunk.lines.map((line, index) => (
<div
key={`old-${line.oldLineNumber}-${index}`}
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
>
{line.type !== "added" &&
line.type !== "hunk" &&
line.oldLineNumber
? line.oldLineNumber
: " "}
</div>
))}
</div>
{/* 新行番号列 */}
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
{hunk.lines.map((line, index) => (
<div
key={`new-${line.newLineNumber}-${index}`}
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
>
{line.type !== "deleted" &&
line.type !== "hunk" &&
line.newLineNumber
? line.newLineNumber
: " "}
</div>
))}
</div>
</div>
{/* コンテンツ列(スクロール可能) */}
<div className="flex-1 min-w-0">
{hunk.lines.map((line, index) => (
<div
key={`content-${line.content}-${line.oldLineNumber}-${line.newLineNumber}-${index}`}
className={cn("flex border-l-4", {
"bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800/50 border-l-green-400":
line.type === "added",
"bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800/50 border-l-red-400":
line.type === "deleted",
"bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800/50 border-l-blue-400":
line.type === "hunk",
"bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 border-l-transparent":
line.type === "unchanged",
})}
>
<div className="flex-1 px-2 py-1">
<span className="font-mono text-sm whitespace-pre block">
<span
className={cn({
"text-green-600 dark:text-green-400": line.type === "added",
"text-red-600 dark:text-red-400": line.type === "deleted",
"text-blue-600 dark:text-blue-400 font-medium":
line.type === "hunk",
"text-gray-400 dark:text-gray-600":
line.type === "unchanged",
})}
>
{line.type === "added"
? "+"
: line.type === "deleted"
? "-"
: line.type === "hunk"
? ""
: " "}
</span>
{line.content || " "}
</span>
</div>
</div>
))}
</div>
</div>
);
};
interface FileHeaderProps {
fileDiff: FileDiff;
isCollapsed: boolean;
onToggleCollapse: () => void;
}
const FileHeader: FC<FileHeaderProps> = ({
fileDiff,
isCollapsed,
onToggleCollapse,
}) => {
const getFileStatusIcon = () => {
if (fileDiff.isNew)
return <span className="text-green-600 dark:text-green-400">A</span>;
if (fileDiff.isDeleted)
return <span className="text-red-600 dark:text-red-400">D</span>;
if (fileDiff.isRenamed)
return <span className="text-blue-600 dark:text-blue-400">R</span>;
return <span className="text-gray-600 dark:text-gray-400">M</span>;
};
const getFileStatusText = () => {
if (fileDiff.isNew) return "added";
if (fileDiff.isDeleted) return "deleted";
if (fileDiff.isRenamed) return `renamed from ${fileDiff.oldFilename ?? ""}`;
return "modified";
};
const handleCopyFilename = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(fileDiff.filename);
toast.success("ファイル名をコピーしました");
} catch (err) {
console.error("Failed to copy filename:", err);
toast.error("ファイル名のコピーに失敗しました");
}
};
return (
<Button
onClick={onToggleCollapse}
className="w-full bg-gray-50 dark:bg-gray-800 px-4 py-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors min-h-[4rem]"
>
<div className="w-full space-y-1">
{/* Row 1: icon, status, and stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isCollapsed ? (
<ChevronRightIcon className="w-4 h-4 text-gray-500" />
) : (
<ChevronDownIcon className="w-4 h-4 text-gray-500" />
)}
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-mono">
{getFileStatusIcon()}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{getFileStatusText()}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
{fileDiff.linesAdded > 0 && (
<span className="text-green-600 dark:text-green-400">
+{fileDiff.linesAdded}
</span>
)}
{fileDiff.linesDeleted > 0 && (
<span className="text-red-600 dark:text-red-400">
-{fileDiff.linesDeleted}
</span>
)}
</div>
</div>
{/* Row 2: filename with copy button */}
<div className="w-full flex items-center gap-2">
<span className="font-mono text-sm font-medium text-black dark:text-white text-left truncate flex-1 min-w-0">
{fileDiff.filename}
</span>
<Button
onClick={handleCopyFilename}
variant="ghost"
size="sm"
className="flex-shrink-0 p-1 h-6 w-6 hover:bg-gray-200 dark:hover:bg-gray-600"
>
<CopyIcon className="w-3 h-3 text-gray-500 dark:text-gray-400" />
</Button>
</div>
</div>
{fileDiff.isBinary && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-left">
Binary file (content not shown)
</div>
)}
</Button>
);
};
export const DiffViewer: FC<DiffViewerProps> = ({ fileDiff, className }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
if (fileDiff.isBinary) {
return (
<div
className={cn(
"border border-gray-200 dark:border-gray-700 rounded-lg",
className,
)}
>
<FileHeader
fileDiff={fileDiff}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapse}
/>
{!isCollapsed && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
Binary file cannot be displayed
</div>
)}
</div>
);
}
return (
<div
className={cn(
"border border-gray-200 dark:border-gray-700 rounded-lg",
className,
)}
>
<FileHeader
fileDiff={fileDiff}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapse}
/>
{!isCollapsed && (
<div className="border-t border-gray-200 dark:border-gray-700">
{fileDiff.hunks.map((hunk, index) => (
<DiffHunkComponent
key={`${hunk.oldStart}-${hunk.newStart}-${index}`}
hunk={hunk}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,76 @@
// API response types for Git operations
export interface GitBranch {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
}
export interface GitBranchesResponse {
success: true;
data: GitBranch[];
}
export interface GitFileInfo {
filePath: string;
status: "added" | "modified" | "deleted" | "renamed" | "copied";
additions: number;
deletions: number;
oldPath?: string;
}
export interface GitDiffLine {
type: "added" | "deleted" | "unchanged" | "hunk";
oldLineNumber?: number;
newLineNumber?: number;
content: string;
}
export interface GitDiffHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: GitDiffLine[];
}
export interface GitFileDiff {
file: GitFileInfo;
hunks: GitDiffHunk[];
}
export interface GitDiffSummary {
totalFiles: number;
totalAdditions: number;
totalDeletions: number;
}
export interface GitDiffResponse {
success: true;
data: {
files: GitFileInfo[];
diffs: GitFileDiff[];
summary: GitDiffSummary;
};
}
export interface GitErrorResponse {
success: false;
error: {
code:
| "NOT_A_REPOSITORY"
| "BRANCH_NOT_FOUND"
| "COMMAND_FAILED"
| "PARSE_ERROR";
message: string;
command?: string;
stderr?: string;
};
}
export type GitApiResponse =
| GitBranchesResponse
| GitDiffResponse
| GitErrorResponse;

View File

@@ -0,0 +1,10 @@
export { DiffModal } from "./DiffModal";
export { DiffViewer } from "./DiffViewer";
export type {
DiffHunk,
DiffLine,
DiffModalProps,
DiffSummary,
FileDiff,
GitRef,
} from "./types";

View File

@@ -0,0 +1,48 @@
export interface DiffLine {
type: "added" | "deleted" | "unchanged" | "hunk" | "context";
oldLineNumber?: number;
newLineNumber?: number;
content: string;
}
export interface DiffHunk {
oldStart: number;
// oldLines: number;
newStart: number;
// newLines: number;
lines: DiffLine[];
}
export interface FileDiff {
filename: string;
oldFilename?: string;
isNew: boolean;
isDeleted: boolean;
isRenamed: boolean;
isBinary: boolean;
hunks: DiffHunk[];
linesAdded: number;
linesDeleted: number;
}
export interface GitRef {
name: `branch:${string}` | `commit:${string}` | `HEAD` | "working";
type: "branch" | "commit" | "head" | "working";
sha?: string;
displayName: string;
}
export interface DiffSummary {
filesChanged: number;
insertions: number;
deletions: number;
files: FileDiff[];
}
export interface DiffModalProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
defaultCompareFrom?: string;
defaultCompareTo?: string;
}

View File

@@ -1,15 +1,9 @@
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 type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
CommandCompletion,
type CommandCompletionRef,
} from "../../../../components/newChat/CommandCompletion";
ChatInput,
useResumeChatMutation,
} from "../../../../components/chatForm";
export const ResumeChat: FC<{
projectId: string;
@@ -17,128 +11,41 @@ export const ResumeChat: FC<{
isPausedTask: boolean;
isRunningTask: boolean;
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resumeChat = useResumeChatMutation(projectId, sessionId);
const { config } = useConfig();
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: async (response) => {
if (sessionId !== response.sessionId) {
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
}
setMessage("");
},
});
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
resumeChat.mutate({ message: message.trim() });
const handleSubmit = async (message: string) => {
await resumeChat.mutateAsync({ message });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
const getButtonText = () => {
if (isPausedTask || isRunningTask) {
return "Send";
}
return "Resume";
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
return "Type your message... (Start with / for commands, Enter to send)";
}
return "Type your message... (Start with / for commands, Shift+Enter to send)";
};
return (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
{resumeChat.error && (
<div className="flex items-center gap-2 p-3 mb-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-2">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Start with / for commands, Shift+Enter to send)"
className="min-h-[60px] 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
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || resumeChat.isPending}
size="default"
className="gap-2"
>
{resumeChat.isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : isPausedTask || isRunningTask ? (
<>
<SendIcon className="w-4 h-4" />
Send
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Resume
</>
)}
</Button>
</div>
</div>
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={resumeChat.isPending}
error={resumeChat.error}
placeholder={getPlaceholder()}
buttonText={getButtonText()}
minHeight="min-h-[100px]"
containerClassName="space-y-2"
buttonSize="default"
/>
</div>
);
};

View File

@@ -79,7 +79,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "mcp":
return <McpTab />;
case "settings":
return <SettingsTab />;
return <SettingsTab openingProjectId={projectId} />;
default:
return null;
}

View File

@@ -1,6 +1,12 @@
"use client";
import { MessageSquareIcon, PlugIcon, SettingsIcon } from "lucide-react";
import {
MessageSquareIcon,
PlugIcon,
SettingsIcon,
Undo2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useState } from "react";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
@@ -22,6 +28,7 @@ export const SessionSidebar: FC<{
isMobileOpen = false,
onMobileOpenChange,
}) => {
const router = useRouter();
const {
data: { sessions },
} = useProject(projectId);
@@ -54,7 +61,7 @@ export const SessionSidebar: FC<{
case "mcp":
return <McpTab />;
case "settings":
return <SettingsTab />;
return <SettingsTab openingProjectId={projectId} />;
default:
return null;
}
@@ -70,6 +77,21 @@ export const SessionSidebar: FC<{
{/* Vertical Icon Menu - Always Visible */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
<div className="flex flex-col p-2 space-y-1">
<button
type="button"
onClick={() => {
router.push(`/projects/${projectId}`);
}}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"text-sidebar-foreground/70",
)}
title="Back to Project"
>
<Undo2Icon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("sessions")}

View File

@@ -1,9 +1,12 @@
"use client";
import type { FC } from "react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
export const SettingsTab: FC = () => {
export const SettingsTab: FC<{
openingProjectId: string;
}> = ({ openingProjectId }) => {
return (
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
@@ -20,7 +23,16 @@ export const SettingsTab: FC = () => {
Session Display
</h3>
<SettingsControls />
<SettingsControls openingProjectId={openingProjectId} />
</div>
{/* Notification Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { honoClient } from "@/lib/api/client";
export const useGitBranches = (projectId: string) => {
return useQuery({
queryKey: ["git", "branches", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.branches.$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch branches: ${response.statusText}`);
}
return response.json();
},
staleTime: 30000, // 30 seconds
});
};
export const useGitCommits = (projectId: string) => {
return useQuery({
queryKey: ["git", "commits", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[
":projectId"
].git.commits.$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(`Failed to fetch commits: ${response.statusText}`);
}
return response.json();
},
staleTime: 30000, // 30 seconds
});
};
export const useGitDiff = () => {
return useMutation({
mutationFn: async ({
projectId,
fromRef,
toRef,
}: {
projectId: string;
fromRef: string;
toRef: string;
}) => {
const response = await honoClient.api.projects[
":projectId"
].git.diff.$post({
param: { projectId },
json: { fromRef, toRef },
});
if (!response.ok) {
throw new Error(`Failed to get diff: ${response.statusText}`);
}
return response.json();
},
});
};

View File

@@ -0,0 +1,104 @@
"use client";
import { useAtom } from "jotai";
import { type FC, useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type NotificationSoundType,
notificationSettingsAtom,
} from "@/lib/atoms/notifications";
import {
getAvailableSoundTypes,
getSoundDisplayName,
playNotificationSound,
} from "@/lib/notifications";
interface NotificationSettingsProps {
showLabels?: boolean;
showDescriptions?: boolean;
className?: string;
}
export const NotificationSettings: FC<NotificationSettingsProps> = ({
showLabels = true,
showDescriptions = true,
className = "",
}: NotificationSettingsProps) => {
const selectId = useId();
const [settings, setSettings] = useAtom(notificationSettingsAtom);
const handleSoundTypeChange = useCallback(
(value: NotificationSoundType) => {
setSettings((prev) => ({
...prev,
soundType: value,
}));
},
[setSettings],
);
const handleTestSound = useCallback(() => {
if (settings.soundType !== "none") {
playNotificationSound(settings.soundType);
}
}, [settings.soundType]);
const availableSoundTypes = getAvailableSoundTypes();
return (
<div className={`space-y-4 ${className}`}>
<div className="space-y-2">
{showLabels && (
<label
htmlFor={selectId}
className="text-sm font-medium leading-none"
>
Task completion sound
</label>
)}
<div className="flex items-center gap-2">
<Select
value={settings.soundType}
onValueChange={handleSoundTypeChange}
>
<SelectTrigger id={selectId} className="w-[180px]">
<SelectValue placeholder="音を選択" />
</SelectTrigger>
<SelectContent>
{availableSoundTypes.map((soundType) => (
<SelectItem key={soundType} value={soundType}>
{getSoundDisplayName(soundType)}
</SelectItem>
))}
</SelectContent>
</Select>
{settings.soundType !== "none" && (
<Button
variant="outline"
size="sm"
onClick={handleTestSound}
className="px-3"
>
</Button>
)}
</div>
{showDescriptions && (
<p className="text-xs text-muted-foreground">
Claude Code
</p>
)}
</div>
</div>
);
};

View File

@@ -1,40 +1,55 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { type FC, useId } from "react";
import { type FC, useCallback, useId } from "react";
import { configQueryConfig, useConfig } from "@/app/hooks/useConfig";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject";
interface SettingsControlsProps {
openingProjectId: string;
showLabels?: boolean;
showDescriptions?: boolean;
className?: string;
onConfigChange?: () => void;
}
export const SettingsControls: FC<SettingsControlsProps> = ({
openingProjectId,
showLabels = true,
showDescriptions = true,
className = "",
onConfigChange,
}: SettingsControlsProps) => {
const checkboxId = useId();
const enterKeyBehaviorId = useId();
const { config, updateConfig } = useConfig();
const queryClient = useQueryClient();
const onConfigChanged = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey,
});
await queryClient.invalidateQueries({
queryKey: ["projects"],
});
void queryClient.invalidateQueries({
queryKey: projectQueryConfig(openingProjectId).queryKey,
});
}, [queryClient, openingProjectId]);
const handleHideNoUserMessageChange = async () => {
const newConfig = {
...config,
hideNoUserMessageSession: !config?.hideNoUserMessageSession,
};
updateConfig(newConfig);
await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey,
});
await queryClient.invalidateQueries({
queryKey: ["projects"],
});
onConfigChange?.();
await onConfigChanged();
};
const handleUnifySameTitleChange = async () => {
@@ -43,13 +58,16 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
unifySameTitleSession: !config?.unifySameTitleSession,
};
updateConfig(newConfig);
await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey,
});
await queryClient.invalidateQueries({
queryKey: ["projects"],
});
onConfigChange?.();
await onConfigChanged();
};
const handleEnterKeyBehaviorChange = async (value: string) => {
const newConfig = {
...config,
enterKeyBehavior: value as "shift-enter-send" | "enter-send",
};
updateConfig(newConfig);
await onConfigChanged();
};
return (
@@ -96,6 +114,36 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
title
</p>
)}
<div className="space-y-2">
{showLabels && (
<label
htmlFor={enterKeyBehaviorId}
className="text-sm font-medium leading-none"
>
Enter Key Behavior
</label>
)}
<Select
value={config?.enterKeyBehavior || "shift-enter-send"}
onValueChange={handleEnterKeyBehaviorChange}
>
<SelectTrigger id={enterKeyBehaviorId} className="w-full">
<SelectValue placeholder="Select enter key behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shift-enter-send">
Shift+Enter to send (default)
</SelectItem>
<SelectItem value="enter-send">Enter to send</SelectItem>
</SelectContent>
</Select>
{showDescriptions && (
<p className="text-xs text-muted-foreground mt-1">
Choose how the Enter key behaves in message input
</p>
)}
</div>
</div>
);
};

View File

@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className,
)}
{...props}

View File

@@ -0,0 +1,175 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value {...props} />;
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { honoClient } from "../lib/api/client";
export type FileCompletionEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type FileCompletionResult = {
entries: FileCompletionEntry[];
basePath: string;
projectPath: string;
};
export const useFileCompletion = (
projectId: string,
basePath: string,
enabled = true,
) => {
return useQuery({
queryKey: ["file-completion", projectId, basePath],
queryFn: async (): Promise<FileCompletionResult> => {
const response = await honoClient.api.projects[":projectId"][
"file-completion"
].$get({
param: { projectId },
query: { basePath },
});
if (!response.ok) {
throw new Error("Failed to fetch file completion");
}
return response.json();
},
enabled: enabled && !!projectId,
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
});
};

View File

@@ -0,0 +1,39 @@
import { useAtomValue } from "jotai";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import {
notificationSettingsAtom,
soundNotificationsEnabledAtom,
} from "@/lib/atoms/notifications";
import { playNotificationSound } from "@/lib/notifications";
/**
* Hook to handle task completion sound notifications
* Monitors task state changes and triggers sound when tasks complete
*/
export const useTaskNotifications = (isRunningTask: boolean) => {
const settings = useAtomValue(notificationSettingsAtom);
const soundEnabled = useAtomValue(soundNotificationsEnabledAtom);
// Track previous running state to detect completion
const prevIsRunningRef = useRef<boolean>(isRunningTask);
// Monitor task state changes
useEffect(() => {
const prevIsRunning = prevIsRunningRef.current;
const currentIsRunning = isRunningTask;
// Update the ref for next comparison
prevIsRunningRef.current = currentIsRunning;
// Detect task completion: was running, now not running
if (prevIsRunning && !currentIsRunning) {
toast.success("Task completed");
if (soundEnabled) {
// Play notification sound
playNotificationSound(settings.soundType);
}
}
}, [isRunningTask, soundEnabled, settings.soundType]);
};

View File

@@ -0,0 +1,34 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Available sound types for notifications
*/
export type NotificationSoundType = "none" | "beep" | "chime" | "ping" | "pop";
/**
* Notification settings stored in localStorage
*/
export interface NotificationSettings {
soundType: NotificationSoundType;
}
const defaultSettings: NotificationSettings = {
soundType: "none",
};
/**
* Atom for notification settings with localStorage persistence
*/
export const notificationSettingsAtom = atomWithStorage<NotificationSettings>(
"claude-code-viewer-notification-settings",
defaultSettings,
);
/**
* Derived atom to check if sound notifications are enabled
*/
export const soundNotificationsEnabledAtom = atom((get) => {
const settings = get(notificationSettingsAtom);
return settings.soundType !== "none";
});

View File

@@ -1,12 +1,10 @@
import { z } from "zod";
export const ImageContentSchema = z
.object({
type: z.literal("image"),
source: z.object({
type: z.literal("base64"),
data: z.string(),
media_type: z.enum(["image/png"]),
}),
})
.strict();
export const ImageContentSchema = z.object({
type: z.literal("image"),
source: z.object({
type: z.literal("base64"),
data: z.string(),
media_type: z.enum(["image/png"]),
}),
});

View File

@@ -1,8 +1,6 @@
import { z } from "zod";
export const TextContentSchema = z
.object({
type: z.literal("text"),
text: z.string(),
})
.strict();
export const TextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});

View File

@@ -1,9 +1,7 @@
import { z } from "zod";
export const ThinkingContentSchema = z
.object({
type: z.literal("thinking"),
thinking: z.string(),
signature: z.string().optional(),
})
.strict();
export const ThinkingContentSchema = z.object({
type: z.literal("thinking"),
thinking: z.string(),
signature: z.string().optional(),
});

View File

@@ -2,16 +2,14 @@ import { z } from "zod";
import { ImageContentSchema } from "./ImageContentSchema";
import { TextContentSchema } from "./TextContentSchema";
export const ToolResultContentSchema = z
.object({
type: z.literal("tool_result"),
tool_use_id: z.string(),
content: z.union([
z.string(),
z.array(z.union([TextContentSchema, ImageContentSchema])),
]),
is_error: z.boolean().optional(),
})
.strict();
export const ToolResultContentSchema = z.object({
type: z.literal("tool_result"),
tool_use_id: z.string(),
content: z.union([
z.string(),
z.array(z.union([TextContentSchema, ImageContentSchema])),
]),
is_error: z.boolean().optional(),
});
export type ToolResultContent = z.infer<typeof ToolResultContentSchema>;

View File

@@ -1,10 +1,8 @@
import { z } from "zod";
export const ToolUseContentSchema = z
.object({
type: z.literal("tool_use"),
id: z.string(),
name: z.string(),
input: z.record(z.string(), z.unknown()),
})
.strict();
export const ToolUseContentSchema = z.object({
type: z.literal("tool_use"),
id: z.string(),
name: z.string(),
input: z.record(z.string(), z.unknown()),
});

View File

@@ -12,4 +12,4 @@ export const AssistantEntrySchema = BaseEntrySchema.extend({
// optional
requestId: z.string().optional(),
isApiErrorMessage: z.boolean().optional(),
}).strict();
});

View File

@@ -1,23 +1,21 @@
import { z } from "zod";
export const BaseEntrySchema = z
.object({
// required
isSidechain: z.boolean(),
userType: z.enum(["external"]),
cwd: z.string(),
sessionId: z.string(),
version: z.string(),
uuid: z.uuid(),
timestamp: z.string(),
export const BaseEntrySchema = z.object({
// required
isSidechain: z.boolean(),
userType: z.enum(["external"]),
cwd: z.string(),
sessionId: z.string(),
version: z.string(),
uuid: z.uuid(),
timestamp: z.string(),
// nullable
parentUuid: z.uuid().nullable(),
// nullable
parentUuid: z.uuid().nullable(),
// optional
isMeta: z.boolean().optional(),
toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown
gitBranch: z.string().optional(),
isCompactSummary: z.boolean().optional(),
})
.strict();
// optional
isMeta: z.boolean().optional(),
toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown
gitBranch: z.string().optional(),
isCompactSummary: z.boolean().optional(),
});

View File

@@ -1,9 +1,7 @@
import { z } from "zod";
export const SummaryEntrySchema = z
.object({
type: z.literal("summary"),
summary: z.string(),
leafUuid: z.string().uuid(),
})
.strict();
export const SummaryEntrySchema = z.object({
type: z.literal("summary"),
summary: z.string(),
leafUuid: z.string().uuid(),
});

View File

@@ -9,4 +9,4 @@ export const SystemEntrySchema = BaseEntrySchema.extend({
content: z.string(),
toolUseID: z.string(),
level: z.enum(["info"]),
}).strict();
});

View File

@@ -8,4 +8,4 @@ export const UserEntrySchema = BaseEntrySchema.extend({
// required
message: UserMessageSchema,
}).strict();
});

View File

@@ -15,32 +15,31 @@ export type AssistantMessageContent = z.infer<
typeof AssistantMessageContentSchema
>;
export const AssistantMessageSchema = z
.object({
id: z.string(),
type: z.literal("message"),
role: z.literal("assistant"),
model: z.string(),
content: z.array(AssistantMessageContentSchema),
stop_reason: z.string().nullable(),
stop_sequence: z.string().nullable(),
usage: z.object({
input_tokens: z.number(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional(),
cache_creation: z
.object({
ephemeral_5m_input_tokens: z.number(),
ephemeral_1h_input_tokens: z.number(),
})
.optional(),
output_tokens: z.number(),
service_tier: z.string().nullable().optional(),
server_tool_use: z
.object({
web_search_requests: z.number(),
})
.optional(),
}),
})
.strict();
export const AssistantMessageSchema = z.object({
id: z.string(),
container: z.null().optional(),
type: z.literal("message"),
role: z.literal("assistant"),
model: z.string(),
content: z.array(AssistantMessageContentSchema),
stop_reason: z.string().nullable(),
stop_sequence: z.string().nullable(),
usage: z.object({
input_tokens: z.number(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional(),
cache_creation: z
.object({
ephemeral_5m_input_tokens: z.number(),
ephemeral_1h_input_tokens: z.number(),
})
.optional(),
output_tokens: z.number(),
service_tier: z.string().nullable().optional(),
server_tool_use: z
.object({
web_search_requests: z.number(),
})
.optional(),
}),
});

View File

@@ -12,12 +12,10 @@ const UserMessageContentSchema = z.union([
export type UserMessageContent = z.infer<typeof UserMessageContentSchema>;
export const UserMessageSchema = z
.object({
role: z.literal("user"),
content: z.union([
z.string(),
z.array(z.union([z.string(), UserMessageContentSchema])),
]),
})
.strict();
export const UserMessageSchema = z.object({
role: z.literal("user"),
content: z.union([
z.string(),
z.array(z.union([z.string(), UserMessageContentSchema])),
]),
});

View File

@@ -2,70 +2,58 @@ import { z } from "zod";
import { StructuredPatchSchema } from "./StructuredPatchSchema";
export const CommonToolResultSchema = z.union([
z
.object({
stdout: z.string(),
stderr: z.string(),
interrupted: z.boolean(),
isImage: z.boolean(),
})
.strict(),
z.object({
stdout: z.string(),
stderr: z.string(),
interrupted: z.boolean(),
isImage: z.boolean(),
}),
// create
z
.object({
type: z.literal("create"),
filePath: z.string(),
content: z.string(),
structuredPatch: z.array(StructuredPatchSchema),
})
.strict(),
z.object({
type: z.literal("create"),
filePath: z.string(),
content: z.string(),
structuredPatch: z.array(StructuredPatchSchema),
}),
// update
z
.object({
filePath: z.string(),
oldString: z.string(),
newString: z.string(),
originalFile: z.string(),
userModified: z.boolean(),
replaceAll: z.boolean(),
structuredPatch: z.array(StructuredPatchSchema),
})
.strict(),
z.object({
filePath: z.string(),
oldString: z.string(),
newString: z.string(),
originalFile: z.string(),
userModified: z.boolean(),
replaceAll: z.boolean(),
structuredPatch: z.array(StructuredPatchSchema),
}),
// search?
z
.object({
filenames: z.array(z.string()),
durationMs: z.number(),
numFiles: z.number(),
truncated: z.boolean(),
})
.strict(),
z.object({
filenames: z.array(z.string()),
durationMs: z.number(),
numFiles: z.number(),
truncated: z.boolean(),
}),
// text
z
.object({
type: z.literal("text"),
file: z.object({
filePath: z.string(),
content: z.string(),
numLines: z.number(),
startLine: z.number(),
totalLines: z.number(),
}),
})
.strict(),
// content
z
.object({
mode: z.literal("content"),
numFiles: z.number(),
filenames: z.array(z.string()),
z.object({
type: z.literal("text"),
file: z.object({
filePath: z.string(),
content: z.string(),
numLines: z.number(),
})
.strict(),
startLine: z.number(),
totalLines: z.number(),
}),
}),
// content
z.object({
mode: z.literal("content"),
numFiles: z.number(),
filenames: z.array(z.string()),
content: z.string(),
numLines: z.number(),
}),
]);

View File

@@ -1,11 +1,9 @@
import { z } from "zod";
export const StructuredPatchSchema = z
.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
})
.strict();
export const StructuredPatchSchema = z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
});

View File

@@ -1,17 +1,13 @@
import z from "zod";
const TodoSchema = z
.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed"]),
priority: z.enum(["low", "medium", "high"]),
id: z.string(),
})
.strict();
const TodoSchema = z.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed"]),
priority: z.enum(["low", "medium", "high"]),
id: z.string(),
});
export const TodoToolResultSchema = z
.object({
oldTodos: z.array(TodoSchema).optional(),
newTodos: z.array(TodoSchema).optional(),
})
.strict();
export const TodoToolResultSchema = z.object({
oldTodos: z.array(TodoSchema).optional(),
newTodos: z.array(TodoSchema).optional(),
});

114
src/lib/notifications.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Audio notification utilities for task completion alerts
*/
import type { NotificationSoundType } from "./atoms/notifications";
/**
* Sound configuration for different notification types
*/
const soundConfigs: Record<
Exclude<NotificationSoundType, "none">,
{
frequency: number[];
duration: number;
type: OscillatorType;
volume: number;
}
> = {
beep: {
frequency: [800],
duration: 0.15,
type: "sine",
volume: 0.3,
},
chime: {
frequency: [523, 659, 784], // C, E, G notes
duration: 0.4,
type: "sine",
volume: 0.2,
},
ping: {
frequency: [1000],
duration: 0.1,
type: "triangle",
volume: 0.4,
},
pop: {
frequency: [400, 600],
duration: 0.08,
type: "square",
volume: 0.2,
},
};
/**
* Play a notification sound based on the sound type
*/
export function playNotificationSound(soundType: NotificationSoundType) {
if (soundType === "none") {
return;
}
try {
const config = soundConfigs[soundType];
if (!config) {
console.warn(`Unknown sound type: ${soundType}`);
return;
}
const audioContext = new (
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext
)();
// Play multiple frequencies if specified (for chords/sequences)
config.frequency.forEach((freq, index) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(freq, audioContext.currentTime);
oscillator.type = config.type;
// Set volume and fade out
const startTime = audioContext.currentTime + index * 0.05; // Slight delay for sequences
gainNode.gain.setValueAtTime(config.volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
startTime + config.duration,
);
// Play the sound
oscillator.start(startTime);
oscillator.stop(startTime + config.duration);
});
} catch (error) {
console.warn("Failed to play notification sound:", error);
}
}
/**
* Get display name for sound types
*/
export function getSoundDisplayName(soundType: NotificationSoundType): string {
const displayNames: Record<NotificationSoundType, string> = {
none: "なし",
beep: "ビープ",
chime: "チャイム",
ping: "ピン",
pop: "ポップ",
};
return displayNames[soundType];
}
/**
* Get all available sound types
*/
export function getAvailableSoundTypes(): NotificationSoundType[] {
return ["none", "beep", "chime", "ping", "pop"];
}

View File

@@ -3,6 +3,10 @@ import z from "zod";
export const configSchema = z.object({
hideNoUserMessageSession: z.boolean().optional().default(true),
unifySameTitleSession: z.boolean().optional().default(true),
enterKeyBehavior: z
.enum(["shift-enter-send", "enter-send"])
.optional()
.default("shift-enter-send"),
});
export type Config = z.infer<typeof configSchema>;

View File

@@ -11,6 +11,10 @@ import type { SerializableAliveTask } from "../service/claude-code/types";
import { getEventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEventResponse } from "../service/events/sseEventResponse";
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getBranches } from "../service/git/getBranches";
import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { getProject } from "../service/project/getProject";
import { getProjects } from "../service/project/getProjects";
@@ -135,6 +139,37 @@ export const routes = (app: HonoAppType) => {
return c.json({ session });
})
.get(
"/projects/:projectId/file-completion",
zValidator(
"query",
z.object({
basePath: z.string().optional().default("/"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { basePath } = c.req.valid("query");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getFileCompletion(
project.meta.projectPath,
basePath,
);
return c.json(result);
} catch (error) {
console.error("File completion error:", error);
return c.json({ error: "Failed to get file completion" }, 500);
}
},
)
.get("/projects/:projectId/claude-commands", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
@@ -170,6 +205,81 @@ export const routes = (app: HonoAppType) => {
});
})
.get("/projects/:projectId/git/branches", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getBranches(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get branches" }, 500);
}
})
.get("/projects/:projectId/git/commits", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getCommits(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get commits" }, 500);
}
})
.post(
"/projects/:projectId/git/diff",
zValidator(
"json",
z.object({
fromRef: z.string().min(1, "fromRef is required"),
toRef: z.string().min(1, "toRef is required"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { fromRef, toRef } = c.req.valid("json");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getDiff(
project.meta.projectPath,
fromRef,
toRef,
);
return c.json(result);
} catch (error) {
console.error("Get diff error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get diff" }, 500);
}
},
)
.get("/mcp/list", async (c) => {
const { servers } = await getMcpList();
return c.json({ servers });

View File

@@ -0,0 +1,96 @@
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
export type FileCompletionEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type FileCompletionResult = {
entries: FileCompletionEntry[];
basePath: string;
projectPath: string;
};
/**
* Get file and directory completions for a given project path
* @param projectPath - The root project path
* @param basePath - The relative path from project root (default: "/")
* @returns File and directory entries at the specified path level
*/
export const getFileCompletion = async (
projectPath: string,
basePath = "/",
): Promise<FileCompletionResult> => {
// Normalize basePath to prevent directory traversal
const normalizedBasePath = basePath.startsWith("/")
? basePath.slice(1)
: basePath;
const targetPath = resolve(projectPath, normalizedBasePath);
// Security check: ensure target path is within project directory
if (!targetPath.startsWith(resolve(projectPath))) {
throw new Error("Invalid path: outside project directory");
}
// Check if the target path exists
if (!existsSync(targetPath)) {
return {
entries: [],
basePath: normalizedBasePath,
projectPath,
};
}
try {
const dirents = await readdir(targetPath, { withFileTypes: true });
const entries: FileCompletionEntry[] = [];
// Process each directory entry
for (const dirent of dirents) {
// Skip hidden files and directories (starting with .)
if (dirent.name.startsWith(".")) {
continue;
}
const entryPath = join(normalizedBasePath, dirent.name);
if (dirent.isDirectory()) {
entries.push({
name: dirent.name,
type: "directory",
path: entryPath,
});
} else if (dirent.isFile()) {
entries.push({
name: dirent.name,
type: "file",
path: entryPath,
});
}
}
// Sort entries: directories first, then files, both alphabetically
entries.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
entries,
basePath: normalizedBasePath,
projectPath,
};
} catch (error) {
console.error("Error reading directory:", error);
return {
entries: [],
basePath: normalizedBasePath,
projectPath,
};
}
};

View File

@@ -0,0 +1,130 @@
import type { GitBranch, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get all branches (local and remote) in the repository
*/
export async function getBranches(
cwd: string,
): Promise<GitResult<GitBranch[]>> {
// Get all branches with verbose information
const result = await executeGitCommand(["branch", "-vv", "--all"], cwd);
if (!result.success) {
return result as GitResult<GitBranch[]>;
}
try {
const lines = parseLines(result.data);
const branches: GitBranch[] = [];
const seenBranches = new Set<string>();
for (const line of lines) {
// Parse branch line format: " main abc1234 [origin/main: ahead 1] Commit message"
const match = line.match(
/^(\*?\s*)([^\s]+)\s+([a-f0-9]+)(?:\s+\[([^\]]+)\])?\s*(.*)/,
);
if (!match) continue;
const [, prefix, name, commit, tracking] = match;
if (!prefix || !name || !commit) continue;
const current = prefix.includes("*");
// Skip remote tracking branches if we already have the local branch
const cleanName = name.replace("remotes/origin/", "");
if (name.startsWith("remotes/origin/") && seenBranches.has(cleanName)) {
continue;
}
// Parse tracking information
let remote: string | undefined;
let ahead: number | undefined;
let behind: number | undefined;
if (tracking) {
const remoteMatch = tracking.match(/^([^:]+)/);
if (remoteMatch?.[1]) {
remote = remoteMatch[1];
}
const aheadMatch = tracking.match(/ahead (\d+)/);
const behindMatch = tracking.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
}
branches.push({
name: cleanName,
current,
remote,
commit,
ahead,
behind,
});
seenBranches.add(cleanName);
}
return {
success: true,
data: branches,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse branch information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get current branch name
*/
export async function getCurrentBranch(
cwd: string,
): Promise<GitResult<string>> {
const result = await executeGitCommand(["branch", "--show-current"], cwd);
if (!result.success) {
return result as GitResult<string>;
}
const currentBranch = result.data.trim();
if (!currentBranch) {
return {
success: false,
error: {
code: "COMMAND_FAILED",
message: "Could not determine current branch (possibly detached HEAD)",
},
};
}
return {
success: true,
data: currentBranch,
};
}
/**
* Check if a branch exists
*/
export async function branchExists(
cwd: string,
branchName: string,
): Promise<GitResult<boolean>> {
const result = await executeGitCommand(
["rev-parse", "--verify", branchName],
cwd,
);
return {
success: true,
data: result.success,
};
}

View File

@@ -0,0 +1,51 @@
import type { GitCommit, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get the last 20 commits from the current branch
*/
export async function getCommits(cwd: string): Promise<GitResult<GitCommit[]>> {
// Get commits with oneline format and limit to 20
const result = await executeGitCommand(
["log", "--oneline", "-n", "20", "--format=%H|%s|%an|%ad", "--date=iso"],
cwd,
);
if (!result.success) {
return result as GitResult<GitCommit[]>;
}
try {
const lines = parseLines(result.data);
const commits: GitCommit[] = [];
for (const line of lines) {
// Parse commit line format: "sha|message|author|date"
const parts = line.split("|");
if (parts.length < 4) continue;
const [sha, message, author, date] = parts;
if (!sha || !message || !author || !date) continue;
commits.push({
sha: sha.trim(),
message: message.trim(),
author: author.trim(),
date: date.trim(),
});
}
return {
success: true,
data: commits,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse commit information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}

View File

@@ -0,0 +1,385 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import parseGitDiff, {
type AnyChunk,
type AnyFileChange,
} from "parse-git-diff";
import type {
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitResult,
} from "./types";
import { executeGitCommand, parseLines, stripAnsiColors } from "./utils";
/**
* Convert parse-git-diff file change to GitDiffFile
*/
function convertToGitDiffFile(
fileChange: AnyFileChange,
fileStats: Map<string, { additions: number; deletions: number }>,
): GitDiffFile {
let filePath: string;
let status: GitDiffFile["status"];
let oldPath: string | undefined;
switch (fileChange.type) {
case "AddedFile":
filePath = fileChange.path;
status = "added";
break;
case "DeletedFile":
filePath = fileChange.path;
status = "deleted";
break;
case "RenamedFile":
filePath = fileChange.pathAfter;
oldPath = fileChange.pathBefore;
status = "renamed";
break;
case "ChangedFile":
filePath = fileChange.path;
status = "modified";
break;
default:
// Fallback for any unknown types
filePath = "";
status = "modified";
}
// Get stats from numstat
const stats = fileStats.get(filePath) ||
fileStats.get(oldPath || "") || { additions: 0, deletions: 0 };
return {
filePath,
status,
additions: stats.additions,
deletions: stats.deletions,
oldPath,
};
}
/**
* Convert parse-git-diff chunk to GitDiffHunk
*/
function convertToGitDiffHunk(chunk: AnyChunk): GitDiffHunk {
if (chunk.type !== "Chunk") {
// For non-standard chunks, return empty hunk
return {
oldStart: 0,
oldCount: 0,
newStart: 0,
newCount: 0,
header: "",
lines: [],
};
}
const lines: GitDiffLine[] = [];
for (const change of chunk.changes) {
let line: GitDiffLine;
switch (change.type) {
case "AddedLine":
line = {
type: "added",
content: change.content,
newLineNumber: change.lineAfter,
};
break;
case "DeletedLine":
line = {
type: "deleted",
content: change.content,
oldLineNumber: change.lineBefore,
};
break;
case "UnchangedLine":
line = {
type: "context",
content: change.content,
oldLineNumber: change.lineBefore,
newLineNumber: change.lineAfter,
};
break;
case "MessageLine":
// This is likely a hunk header or context line
line = {
type: "context",
content: change.content,
};
break;
default:
// Fallback for unknown line types
line = {
type: "context",
content: "",
};
}
lines.push(line);
}
return {
oldStart: chunk.fromFileRange.start,
oldCount: chunk.fromFileRange.lines,
newStart: chunk.toFileRange.start,
newCount: chunk.toFileRange.lines,
header: `@@ -${chunk.fromFileRange.start},${chunk.fromFileRange.lines} +${chunk.toFileRange.start},${chunk.toFileRange.lines} @@${chunk.context ? ` ${chunk.context}` : ""}`,
lines,
};
}
const extractRef = (refText: string) => {
const [group, ref] = refText.split(":");
if (group === undefined || ref === undefined) {
if (refText === "HEAD") {
return "HEAD";
}
if (refText === "working") {
return undefined;
}
throw new Error(`Invalid ref text: ${refText}`);
}
return ref;
};
/**
* Get untracked files using git status
*/
async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
const statusResult = await executeGitCommand(
["status", "--untracked-files=all", "--short"],
cwd,
);
console.log("debug statusResult stdout", statusResult);
if (!statusResult.success) {
return statusResult;
}
try {
const untrackedFiles = parseLines(statusResult.data)
.map((line) => stripAnsiColors(line)) // Remove ANSI color codes first
.filter((line) => line.startsWith("??"))
.map((line) => line.slice(3));
return {
success: true,
data: untrackedFiles,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse status output: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Create artificial diff for an untracked file (all lines as additions)
*/
async function createUntrackedFileDiff(
cwd: string,
filePath: string,
): Promise<GitDiff | null> {
try {
const fullPath = resolve(cwd, filePath);
const content = await readFile(fullPath, "utf8");
const lines = content.split("\n");
const diffLines: GitDiffLine[] = lines.map((line, index) => ({
type: "added" as const,
content: line,
newLineNumber: index + 1,
}));
const file: GitDiffFile = {
filePath,
status: "added",
additions: lines.length,
deletions: 0,
};
const hunk: GitDiffHunk = {
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: lines.length,
header: `@@ -0,0 +1,${lines.length} @@`,
lines: diffLines,
};
return {
file,
hunks: [hunk],
};
} catch (error) {
// Skip files that can't be read (e.g., binary files, permission errors)
console.warn(`Failed to read untracked file ${filePath}:`, error);
return null;
}
}
/**
* Get Git diff between two references (branches, commits, tags)
*/
export const getDiff = async (
cwd: string,
fromRefText: string,
toRefText: string,
): Promise<GitResult<GitComparisonResult>> => {
const fromRef = extractRef(fromRefText);
const toRef = extractRef(toRefText);
if (fromRef === toRef) {
return {
success: true,
data: {
diffs: [],
files: [],
summary: {
totalFiles: 0,
totalAdditions: 0,
totalDeletions: 0,
},
},
};
}
if (fromRef === undefined) {
throw new Error(`Invalid fromRef: ${fromRefText}`);
}
const commandArgs = toRef === undefined ? [fromRef] : [fromRef, toRef];
// Get diff with numstat for file statistics
const numstatResult = await executeGitCommand(
["diff", "--numstat", ...commandArgs],
cwd,
);
if (!numstatResult.success) {
return numstatResult;
}
// Get diff with full content
const diffResult = await executeGitCommand(
["diff", "--unified=5", ...commandArgs],
cwd,
);
if (!diffResult.success) {
return diffResult;
}
try {
// Parse numstat output to get file statistics
const fileStats = new Map<
string,
{ additions: number; deletions: number }
>();
const numstatLines = parseLines(numstatResult.data);
for (const line of numstatLines) {
const parts = line.split("\t");
if (parts.length >= 3 && parts[0] && parts[1] && parts[2]) {
const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
const filePath = parts[2];
fileStats.set(filePath, { additions, deletions });
}
}
// Parse diff output using parse-git-diff
const parsedDiff = parseGitDiff(diffResult.data);
const files: GitDiffFile[] = [];
const diffs: GitDiff[] = [];
let totalAdditions = 0;
let totalDeletions = 0;
for (const fileChange of parsedDiff.files) {
// Convert to GitDiffFile format
const file = convertToGitDiffFile(fileChange, fileStats);
files.push(file);
// Convert chunks to hunks
const hunks: GitDiffHunk[] = [];
for (const chunk of fileChange.chunks) {
const hunk = convertToGitDiffHunk(chunk);
hunks.push(hunk);
}
diffs.push({
file,
hunks,
});
totalAdditions += file.additions;
totalDeletions += file.deletions;
}
// Include untracked files when comparing to working directory
if (toRef === undefined) {
const untrackedResult = await getUntrackedFiles(cwd);
console.log("debug untrackedResult", untrackedResult);
if (untrackedResult.success) {
for (const untrackedFile of untrackedResult.data) {
const untrackedDiff = await createUntrackedFileDiff(
cwd,
untrackedFile,
);
if (untrackedDiff) {
files.push(untrackedDiff.file);
diffs.push(untrackedDiff);
totalAdditions += untrackedDiff.file.additions;
}
}
}
}
return {
success: true,
data: {
files,
diffs,
summary: {
totalFiles: files.length,
totalAdditions,
totalDeletions,
},
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse diff: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
};
/**
* Compare between two branches (shorthand for getDiff)
*/
export async function compareBranches(
cwd: string,
baseBranch: string,
targetBranch: string,
): Promise<GitResult<GitComparisonResult>> {
return getDiff(cwd, baseBranch, targetBranch);
}

View File

@@ -0,0 +1,172 @@
import type { GitDiffFile, GitResult, GitStatus } from "./types";
import {
executeGitCommand,
getFileStatus,
parseLines,
parseStatusLine,
} from "./utils";
/**
* Get git status information including staged, unstaged, and untracked files
*/
export async function getStatus(cwd: string): Promise<GitResult<GitStatus>> {
// Get porcelain status for consistent parsing
const statusResult = await executeGitCommand(
["status", "--porcelain=v1", "-b"],
cwd,
);
if (!statusResult.success) {
return statusResult as GitResult<GitStatus>;
}
try {
const lines = parseLines(statusResult.data);
const staged: GitDiffFile[] = [];
const unstaged: GitDiffFile[] = [];
const untracked: string[] = [];
const conflicted: string[] = [];
let branch = "HEAD";
let ahead = 0;
let behind = 0;
for (const line of lines) {
// Parse branch line
if (line.startsWith("##")) {
const branchMatch = line.match(/^##\s+(.+?)(?:\.\.\.|$)/);
if (branchMatch?.[1]) {
branch = branchMatch[1];
}
const aheadMatch = line.match(/ahead (\d+)/);
const behindMatch = line.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
continue;
}
// Parse file status lines
const { status, filePath, oldPath } = parseStatusLine(line);
const indexStatus = status[0]; // Staged changes
const workTreeStatus = status[1]; // Unstaged changes
// Handle conflicts (both index and work tree have changes)
if (
indexStatus === "U" ||
workTreeStatus === "U" ||
(indexStatus !== " " &&
indexStatus !== "?" &&
workTreeStatus !== " " &&
workTreeStatus !== "?")
) {
conflicted.push(filePath);
continue;
}
// Handle staged changes (index status)
if (indexStatus !== " " && indexStatus !== "?") {
staged.push({
filePath,
status: getFileStatus(`${indexStatus} `),
additions: 0, // We don't have line counts from porcelain status
deletions: 0,
oldPath,
});
}
// Handle unstaged changes (work tree status)
if (workTreeStatus !== " " && workTreeStatus !== "?") {
if (workTreeStatus === "?") {
untracked.push(filePath);
} else {
unstaged.push({
filePath,
status: getFileStatus(` ${workTreeStatus}`),
additions: 0,
deletions: 0,
oldPath,
});
}
}
// Handle untracked files
if (status === "??") {
untracked.push(filePath);
}
}
return {
success: true,
data: {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
conflicted,
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse git status: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get uncommitted changes (both staged and unstaged)
*/
export async function getUncommittedChanges(
cwd: string,
): Promise<GitResult<GitDiffFile[]>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<GitDiffFile[]>;
}
const { staged, unstaged } = statusResult.data;
const allChanges = [...staged, ...unstaged];
// Remove duplicates (files that are both staged and unstaged)
const uniqueChanges = allChanges.reduce((acc: GitDiffFile[], change) => {
const existing = acc.find((c) => c.filePath === change.filePath);
if (!existing) {
acc.push(change);
}
return acc;
}, [] as GitDiffFile[]);
return {
success: true,
data: uniqueChanges,
};
}
/**
* Check if the working directory is clean (no uncommitted changes)
*/
export async function isWorkingDirectoryClean(
cwd: string,
): Promise<GitResult<boolean>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<boolean>;
}
const { staged, unstaged, untracked } = statusResult.data;
const isClean =
staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
return {
success: true,
data: isClean,
};
}

View File

@@ -0,0 +1,32 @@
// Git service utilities for claude-code-viewer
// Provides comprehensive Git operations including branch management, diff generation, and status checking
export * from "./getBranches";
// Re-export main functions for convenience
export { branchExists, getBranches, getCurrentBranch } from "./getBranches";
export * from "./getCommits";
export { getCommits } from "./getCommits";
export * from "./getDiff";
export { compareBranches, getDiff } from "./getDiff";
export * from "./getStatus";
export {
getStatus,
getUncommittedChanges,
isWorkingDirectoryClean,
} from "./getStatus";
// Types re-export for convenience
export type {
GitBranch,
GitCommit,
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitError,
GitResult,
GitStatus,
} from "./types";
export * from "./types";
export * from "./utils";
export { executeGitCommand, isGitRepository } from "./utils";

View File

@@ -0,0 +1,85 @@
export type GitBranch = {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
};
export type GitCommit = {
sha: string;
message: string;
author: string;
date: string;
};
export type GitDiffFile = {
filePath: string;
status: "added" | "modified" | "deleted" | "renamed" | "copied";
additions: number;
deletions: number;
oldPath?: string; // For renamed files
};
export type GitDiffHunk = {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
header: string;
lines: GitDiffLine[];
};
export type GitDiffLine = {
type: "context" | "added" | "deleted";
content: string;
oldLineNumber?: number;
newLineNumber?: number;
};
export type GitDiff = {
file: GitDiffFile;
hunks: GitDiffHunk[];
};
export type GitComparisonResult = {
files: GitDiffFile[];
diffs: GitDiff[];
summary: {
totalFiles: number;
totalAdditions: number;
totalDeletions: number;
};
};
export type GitStatus = {
branch: string;
ahead: number;
behind: number;
staged: GitDiffFile[];
unstaged: GitDiffFile[];
untracked: string[];
conflicted: string[];
};
export type GitError = {
code:
| "NOT_A_REPOSITORY"
| "BRANCH_NOT_FOUND"
| "COMMAND_FAILED"
| "PARSE_ERROR";
message: string;
command?: string;
stderr?: string;
};
export type GitResult<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: GitError;
};

View File

@@ -0,0 +1,151 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { promisify } from "node:util";
import type { GitError, GitResult } from "./types";
const execFileAsync = promisify(execFile);
/**
* Execute a git command in the specified directory
*/
export async function executeGitCommand(
args: string[],
cwd: string,
): Promise<GitResult<string>> {
try {
// Check if the directory exists and contains a git repository
if (!existsSync(cwd)) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
if (!existsSync(resolve(cwd, ".git"))) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
timeout: 30000, // 30 second timeout
});
return {
success: true,
data: stdout,
};
} catch (error: unknown) {
const err = error as { code?: string; stderr?: string; message?: string };
let errorCode: GitError["code"] = "COMMAND_FAILED";
let errorMessage = err.message || "Unknown git command error";
if (err.stderr) {
if (err.stderr.includes("not a git repository")) {
errorCode = "NOT_A_REPOSITORY";
errorMessage = "Not a git repository";
} else if (err.stderr.includes("unknown revision")) {
errorCode = "BRANCH_NOT_FOUND";
errorMessage = "Branch or commit not found";
}
}
return {
success: false,
error: {
code: errorCode,
message: errorMessage,
command: `git ${args.join(" ")}`,
stderr: err.stderr,
},
};
}
}
/**
* Check if a directory is a git repository
*/
export function isGitRepository(cwd: string): boolean {
return existsSync(cwd) && existsSync(resolve(cwd, ".git"));
}
/**
* Remove ANSI color codes from a string
*/
export function stripAnsiColors(text: string): string {
// ANSI escape sequence pattern: \x1B[...m
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is a valid regex
return text.replace(/\x1B\[[0-9;]*m/g, "");
}
/**
* Safely parse git command output that might be empty
*/
export function parseLines(output: string): string[] {
return output
.trim()
.split("\n")
.filter((line) => line.trim() !== "");
}
/**
* Parse git status porcelain output
*/
export function parseStatusLine(line: string): {
status: string;
filePath: string;
oldPath?: string;
} {
const status = line.slice(0, 2);
const filePath = line.slice(3);
// Handle renamed files (R old -> new)
if (status.startsWith("R")) {
const parts = filePath.split(" -> ");
return {
status,
filePath: parts[1] || filePath,
oldPath: parts[0],
};
}
return { status, filePath };
}
/**
* Convert git status code to readable status
*/
export function getFileStatus(
statusCode: string,
): "added" | "modified" | "deleted" | "renamed" | "copied" {
const firstChar = statusCode[0];
switch (firstChar) {
case "A":
return "added";
case "M":
return "modified";
case "D":
return "deleted";
case "R":
return "renamed";
case "C":
return "copied";
default:
return "modified";
}
}