mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-26 17:54:23 +01:00
Merge pull request #23 from d-kimuson/feature/i18n
feat: Add i18n support with English and Japanese
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noProcessEnv": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"useUniqueElementIds": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
20
lingui.config.ts
Normal file
20
lingui.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@lingui/conf";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
import type { SupportedLocale } from "./src/lib/i18n/schema";
|
||||
|
||||
const config = defineConfig({
|
||||
locales: ["ja", "en"] satisfies SupportedLocale[],
|
||||
sourceLocale: "en",
|
||||
fallbackLocales: {
|
||||
default: "en",
|
||||
},
|
||||
catalogs: [
|
||||
{
|
||||
path: "src/lib/i18n/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
},
|
||||
],
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
|
||||
export default config;
|
||||
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
ignoreBuildErrors: true, // typechecking should be separeted by build
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
11
package.json
11
package.json
@@ -37,13 +37,17 @@
|
||||
"test:watch": "vitest",
|
||||
"e2e": "./scripts/e2e/exec_e2e.sh",
|
||||
"e2e:start-server": "./scripts/e2e/start_server.sh",
|
||||
"e2e:capture-snapshots": "./scripts/e2e/capture_snapshots.sh"
|
||||
"e2e:capture-snapshots": "./scripts/e2e/capture_snapshots.sh",
|
||||
"lingui:extract": "lingui extract --clean",
|
||||
"lingui:compile": "lingui compile --typescript"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^2.0.22",
|
||||
"@effect/platform": "^0.92.1",
|
||||
"@effect/platform-node": "^0.98.4",
|
||||
"@hono/zod-validator": "^0.7.4",
|
||||
"@lingui/core": "^5.5.1",
|
||||
"@lingui/react": "^5.5.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -56,6 +60,7 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"effect": "^3.18.4",
|
||||
"es-toolkit": "^1.40.0",
|
||||
"hono": "^4.10.1",
|
||||
@@ -79,6 +84,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.2.6",
|
||||
"@lingui/cli": "^5.5.1",
|
||||
"@lingui/conf": "^5.5.1",
|
||||
"@lingui/format-json": "^5.5.1",
|
||||
"@lingui/loader": "^5.5.1",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@types/node": "^24.8.1",
|
||||
|
||||
1586
pnpm-lock.yaml
generated
1586
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ if [ -d "dist/standalone" ]; then
|
||||
rm -rf dist/standalone
|
||||
fi
|
||||
|
||||
pnpm lingui:compile
|
||||
pnpm exec next build
|
||||
cp -r public .next/standalone/
|
||||
cp -r .next/static .next/standalone/.next/
|
||||
|
||||
@@ -22,20 +22,28 @@ export const useConfig = () => {
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQuery.queryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config: data?.config,
|
||||
updateConfig: useCallback(
|
||||
(config: UserConfig) => {
|
||||
updateConfigMutation.mutate(config);
|
||||
(
|
||||
config: UserConfig,
|
||||
callbacks?: {
|
||||
onSuccess: () => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
updateConfigMutation.mutate(config, {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQuery.queryKey,
|
||||
});
|
||||
|
||||
await callbacks?.onSuccess?.();
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateConfigMutation],
|
||||
[updateConfigMutation, queryClient],
|
||||
),
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -3,16 +3,18 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||
import { SSEProvider } from "../lib/sse/components/SSEProvider";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
|
||||
import "./globals.css";
|
||||
import { honoClient } from "../lib/api/client";
|
||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||
import { configQuery } from "../lib/api/queries";
|
||||
import { LinguiServerProvider } from "../lib/i18n/LinguiServerProvider";
|
||||
import { SSEProvider } from "../lib/sse/components/SSEProvider";
|
||||
import { getUserConfigOnServerComponent } from "../server/lib/config/getUserConfigOnServerComponent";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
import { SSEEventListeners } from "./components/SSEEventListeners";
|
||||
import { SyncSessionProcess } from "./components/SyncSessionProcess";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
@@ -36,6 +38,7 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const userConfig = await getUserConfigOnServerComponent();
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
@@ -48,26 +51,28 @@ export default async function RootLayout({
|
||||
.then((response) => response.json());
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang={userConfig.locale} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>
|
||||
<SSEProvider>
|
||||
<SSEEventListeners>
|
||||
<SyncSessionProcess
|
||||
initProcesses={initSessionProcesses.processes}
|
||||
>
|
||||
{children}
|
||||
</SyncSessionProcess>
|
||||
</SSEEventListeners>
|
||||
</SSEProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
<LinguiServerProvider locale={userConfig.locale}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>
|
||||
<SSEProvider>
|
||||
<SSEEventListeners>
|
||||
<SyncSessionProcess
|
||||
initProcesses={initSessionProcesses.processes}
|
||||
>
|
||||
{children}
|
||||
</SyncSessionProcess>
|
||||
</SSEEventListeners>
|
||||
</SSEProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
</LinguiServerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
LoaderIcon,
|
||||
@@ -18,7 +19,7 @@ export interface ChatInputProps {
|
||||
isPending: boolean;
|
||||
error?: Error | null;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
buttonText: React.ReactNode;
|
||||
minHeight?: string;
|
||||
containerClassName?: string;
|
||||
disabled?: boolean;
|
||||
@@ -37,6 +38,7 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
disabled = false,
|
||||
buttonSize = "lg",
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
const [message, setMessage] = useState("");
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
relative: { top: number; left: number };
|
||||
@@ -168,7 +170,10 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-gradient-to-r from-red-50 to-red-100/50 dark:from-red-950/30 dark:to-red-900/20 border border-red-200/50 dark:border-red-800/50 rounded-xl mb-4 animate-in fade-in slide-in-from-top-2 duration-300 shadow-sm">
|
||||
<AlertCircleIcon className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="font-medium">
|
||||
Failed to send message. Please try again.
|
||||
<Trans
|
||||
id="chat.error.send_failed"
|
||||
message="Failed to send message. Please try again."
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -202,7 +207,7 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
className={`${minHeight} resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent px-5 py-4 text-lg transition-all duration-200 placeholder:text-muted-foreground/60`}
|
||||
disabled={isPending || disabled}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with completion support"
|
||||
aria-label={i18n._("Message input with completion support")}
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/") || message.includes("@")}
|
||||
aria-haspopup="listbox"
|
||||
@@ -223,7 +228,10 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
{(message.startsWith("/") || message.includes("@")) && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium flex items-center gap-1">
|
||||
<SparklesIcon className="w-3 h-3" />
|
||||
Autocomplete active
|
||||
<Trans
|
||||
id="chat.autocomplete.active"
|
||||
message="Autocomplete active"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +245,12 @@ export const ChatInput: FC<ChatInputProps> = ({
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
<span>
|
||||
<Trans
|
||||
id="chat.status.processing"
|
||||
message="Processing..."
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon, TerminalIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
@@ -33,6 +34,7 @@ export const CommandCompletion = forwardRef<
|
||||
CommandCompletionRef,
|
||||
CommandCompletionProps
|
||||
>(({ projectId, inputValue, onCommandSelect, className }, ref) => {
|
||||
const { i18n } = useLingui();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -188,7 +190,7 @@ export const CommandCompletion = forwardRef<
|
||||
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{ height: "15rem" }}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
aria-label={i18n._("Available commands")}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{filteredCommands.length > 0 && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { CheckIcon, FileIcon, FolderIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ export const FileCompletion = forwardRef<
|
||||
FileCompletionRef,
|
||||
FileCompletionProps
|
||||
>(({ projectId, inputValue, onFileSelect, className }, ref) => {
|
||||
const { i18n } = useLingui();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -262,7 +264,7 @@ export const FileCompletion = forwardRef<
|
||||
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{ height: "15rem" }}
|
||||
role="listbox"
|
||||
aria-label="Available files and directories"
|
||||
aria-label={i18n._("Available files and directories")}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{filteredEntries.length > 0 && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../hooks/useConfig";
|
||||
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
|
||||
@@ -6,6 +7,7 @@ export const NewChat: FC<{
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
}> = ({ projectId, onSuccess }) => {
|
||||
const { i18n } = useLingui();
|
||||
const createSessionProcess = useCreateSessionProcessMutation(
|
||||
projectId,
|
||||
onSuccess,
|
||||
@@ -19,12 +21,18 @@ export const NewChat: FC<{
|
||||
const getPlaceholder = () => {
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message here... (Start with / for commands, @ for files, Enter to send)",
|
||||
);
|
||||
}
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message here... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message here... (Start with / for commands, @ for files, Command+Enter to send)",
|
||||
);
|
||||
}
|
||||
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -34,7 +42,7 @@ export const NewChat: FC<{
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText="Start Chat"
|
||||
buttonText={<Trans id="chat.button.start" message="Start Chat" />}
|
||||
minHeight="min-h-[200px]"
|
||||
containerClassName="p-6"
|
||||
buttonSize="lg"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans } from "@lingui/react";
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import {
|
||||
@@ -29,7 +30,7 @@ export const NewChatModal: FC<{
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareIcon className="w-5 h-5" />
|
||||
Start New Chat
|
||||
<Trans id="chat.modal.title" message="Start New Chat" />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewChat projectId={projectId} onSuccess={handleSuccess} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -29,9 +30,17 @@ export default function ProjectErrorPage({
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<div>
|
||||
<CardTitle>Failed to load project</CardTitle>
|
||||
<CardTitle>
|
||||
<Trans
|
||||
id="project.error.title"
|
||||
message="Failed to load project"
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
We encountered an error while loading this project
|
||||
<Trans
|
||||
id="project.error.description"
|
||||
message="We encountered an error while loading this project"
|
||||
/>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,12 +48,15 @@ export default function ProjectErrorPage({
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertTitle>
|
||||
<Trans id="project.error.details_title" message="Error Details" />
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="text-xs">{error.message}</code>
|
||||
{error.digest && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
<Trans id="project.error.error_id" message="Error ID:" />{" "}
|
||||
{error.digest}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
@@ -53,11 +65,14 @@ export default function ProjectErrorPage({
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="default">
|
||||
<RefreshCw />
|
||||
Try Again
|
||||
<Trans id="project.error.try_again" message="Try Again" />
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/projects")} variant="outline">
|
||||
<ArrowLeft />
|
||||
Back to Projects
|
||||
<Trans
|
||||
id="project.error.back_to_projects"
|
||||
message="Back to Projects"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { redirect } from "next/navigation";
|
||||
import { latestSessionQuery } from "../../../../lib/api/queries";
|
||||
import { initializeI18n } from "../../../../lib/i18n/initializeI18n";
|
||||
|
||||
interface LatestSessionPageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
@@ -9,6 +10,8 @@ interface LatestSessionPageProps {
|
||||
export default async function LatestSessionPage({
|
||||
params,
|
||||
}: LatestSessionPageProps) {
|
||||
await initializeI18n();
|
||||
|
||||
const { projectId } = await params;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans } from "@lingui/react";
|
||||
import { FolderSearch, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -9,8 +10,11 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { initializeI18n } from "../../../lib/i18n/initializeI18n";
|
||||
|
||||
export default async function ProjectNotFoundPage() {
|
||||
await initializeI18n();
|
||||
|
||||
export default function ProjectNotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
@@ -18,10 +22,17 @@ export default function ProjectNotFoundPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderSearch className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Project Not Found</CardTitle>
|
||||
<CardTitle>
|
||||
<Trans
|
||||
id="project.not_found.title"
|
||||
message="Project Not Found"
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The project you are looking for does not exist or has been
|
||||
removed
|
||||
<Trans
|
||||
id="project.not_found.description"
|
||||
message="The project you are looking for does not exist or has been removed"
|
||||
/>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,7 +42,10 @@ export default function ProjectNotFoundPage() {
|
||||
<Button asChild variant="default">
|
||||
<Link href="/projects">
|
||||
<Home />
|
||||
Back to Projects
|
||||
<Trans
|
||||
id="project.not_found.back_to_projects"
|
||||
message="Back to Projects"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface ProjectPageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
const { projectId } = await params;
|
||||
redirect(`/projects/${projectId}/latest`);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
GitCompareIcon,
|
||||
@@ -145,7 +146,10 @@ export const SessionPageContent: FC<{
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs sm:text-sm font-medium">
|
||||
Conversation is in progress...
|
||||
<Trans
|
||||
id="session.conversation.in.progress"
|
||||
message="Conversation is in progress..."
|
||||
/>
|
||||
</p>
|
||||
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
|
||||
<div
|
||||
@@ -167,7 +171,9 @@ export const SessionPageContent: FC<{
|
||||
) : (
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
<span className="hidden sm:inline">
|
||||
<Trans id="session.conversation.abort" message="Abort" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -177,7 +183,10 @@ export const SessionPageContent: FC<{
|
||||
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
|
||||
Conversation is paused...
|
||||
<Trans
|
||||
id="session.conversation.paused"
|
||||
message="Conversation is paused..."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -194,7 +203,9 @@ export const SessionPageContent: FC<{
|
||||
) : (
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
<span className="hidden sm:inline">
|
||||
<Trans id="session.conversation.abort" message="Abort" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -220,7 +231,10 @@ export const SessionPageContent: FC<{
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium animate-pulse">
|
||||
Claude Code is processing...
|
||||
<Trans
|
||||
id="session.processing"
|
||||
message="Claude Code is processing..."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { ChevronDown, Lightbulb, Settings } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -49,7 +50,7 @@ export const AssistantConversationContent: FC<{
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground group-hover:text-yellow-600 transition-colors" />
|
||||
<CardTitle className="text-sm font-medium group-hover:text-foreground transition-colors">
|
||||
Thinking
|
||||
<Trans id="assistant.thinking" message="Thinking" />
|
||||
</CardTitle>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
@@ -75,7 +76,9 @@ export const AssistantConversationContent: FC<{
|
||||
<CardHeader className="py-0 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<CardTitle className="text-sm font-medium">Tool Use</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Trans id="assistant.tool_use" message="Tool Use" />
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300"
|
||||
@@ -92,7 +95,10 @@ export const AssistantConversationContent: FC<{
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
|
||||
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
|
||||
Input Parameters
|
||||
<Trans
|
||||
id="assistant.tool.input_parameters"
|
||||
message="Input Parameters"
|
||||
/>
|
||||
</h4>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
@@ -113,7 +119,7 @@ export const AssistantConversationContent: FC<{
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
|
||||
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
|
||||
Tool Result
|
||||
<Trans id="assistant.tool.result" message="Tool Result" />
|
||||
</h4>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { AlertTriangle, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -49,7 +50,10 @@ const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-3 w-3 text-red-500" />
|
||||
<span className="text-xs font-medium text-red-600">
|
||||
Schema Error
|
||||
<Trans
|
||||
id="conversation.error.schema"
|
||||
message="Schema Error"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
@@ -64,25 +68,36 @@ const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="text-red-800">
|
||||
Schema Validation Error
|
||||
<Trans
|
||||
id="conversation.error.schema_validation"
|
||||
message="Schema Validation Error"
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-700">
|
||||
This conversation entry failed to parse correctly. This
|
||||
might indicate a format change or parsing issue.{" "}
|
||||
<Trans
|
||||
id="conversation.error.schema_validation.description"
|
||||
message="This conversation entry failed to parse correctly. This might indicate a format change or parsing issue."
|
||||
/>{" "}
|
||||
<a
|
||||
href="https://github.com/d-kimuson/claude-code-viewer/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-800 underline underline-offset-4"
|
||||
>
|
||||
Report this issue
|
||||
<Trans
|
||||
id="conversation.error.report_issue"
|
||||
message="Report this issue"
|
||||
/>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-gray-50 border rounded px-3 py-2">
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">
|
||||
Raw Content:
|
||||
<Trans
|
||||
id="conversation.error.raw_content"
|
||||
message="Raw Content:"
|
||||
/>
|
||||
</h5>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all font-mono text-gray-800">
|
||||
{errorLine}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { FileHistorySnapshotEntry } from "@/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema";
|
||||
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
|
||||
export const FileHistorySnapshotConversationContent: FC<{
|
||||
conversation: FileHistorySnapshotEntry;
|
||||
@@ -13,6 +15,7 @@ export const FileHistorySnapshotConversationContent: FC<{
|
||||
const fileCount = Object.keys(
|
||||
conversation.snapshot.trackedFileBackups,
|
||||
).length;
|
||||
const { config } = useConfig();
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
@@ -30,7 +33,9 @@ export const FileHistorySnapshotConversationContent: FC<{
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Timestamp: </span>
|
||||
<span>
|
||||
{new Date(conversation.snapshot.timestamp).toLocaleString()}
|
||||
{formatLocaleDate(conversation.snapshot.timestamp, {
|
||||
locale: config.locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans } from "@lingui/react";
|
||||
import { AlertCircle, Image as ImageIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
@@ -34,7 +35,9 @@ export const UserConversationContent: FC<{
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<CardTitle className="text-sm font-medium">Image</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Trans id="user.content.image" message="Image" />
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-300 text-purple-700 dark:border-purple-700 dark:text-purple-300"
|
||||
@@ -43,7 +46,10 @@ export const UserConversationContent: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
User uploaded image content
|
||||
<Trans
|
||||
id="user.content.image.description"
|
||||
message="User uploaded image content"
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -68,12 +74,20 @@ export const UserConversationContent: FC<{
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Unsupported Media
|
||||
<Trans
|
||||
id="user.content.unsupported_media"
|
||||
message="Unsupported Media"
|
||||
/>
|
||||
</CardTitle>
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
<Badge variant="destructive">
|
||||
<Trans id="common.error" message="Error" />
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Media type not supported for display
|
||||
<Trans
|
||||
id="user.content.unsupported_media.description"
|
||||
message="Media type not supported for display"
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, GitBranch, Loader2, RefreshCcwIcon } from "lucide-react";
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,9 +53,12 @@ const DiffSummaryComponent: FC<DiffSummaryProps> = ({ summary, className }) => {
|
||||
<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
|
||||
{summary.filesChanged}{" "}
|
||||
<Trans id="diff.files.changed" message="files changed" />
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{summary.filesChanged} <Trans id="diff.files" message="files" />
|
||||
</span>
|
||||
<span className="sm:hidden">{summary.filesChanged} files</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -104,7 +115,7 @@ const RefSelector: FC<RefSelectorProps> = ({
|
||||
</label>
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="w-full sm:w-80">
|
||||
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent id={id}>
|
||||
{refs.map((ref) => (
|
||||
@@ -133,6 +144,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
defaultCompareFrom = "HEAD",
|
||||
defaultCompareTo = "working",
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
const commitMessageId = useId();
|
||||
const [compareFrom, setCompareFrom] = useState(defaultCompareFrom);
|
||||
const [compareTo, setCompareTo] = useState(defaultCompareTo);
|
||||
@@ -145,6 +157,9 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
// Commit message state
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
|
||||
// Commit section collapse state (default: collapsed)
|
||||
const [isCommitSectionExpanded, setIsCommitSectionExpanded] = useState(false);
|
||||
|
||||
// API hooks
|
||||
const { data: branchesData, isLoading: isLoadingBranches } =
|
||||
useGitBranches(projectId);
|
||||
@@ -167,7 +182,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
{
|
||||
name: "working" as const,
|
||||
type: "working" as const,
|
||||
displayName: "Uncommitted changes",
|
||||
displayName: i18n._("Uncommitted changes"),
|
||||
},
|
||||
{
|
||||
name: "HEAD" as const,
|
||||
@@ -285,7 +300,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("[DiffModal.handleCommit] Error:", _error);
|
||||
toast.error("Failed to commit");
|
||||
toast.error(i18n._("Failed to commit"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,7 +318,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("[DiffModal.handlePush] Error:", _error);
|
||||
toast.error("Failed to push");
|
||||
toast.error(i18n._("Failed to push"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,7 +353,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
`Committed (${result.commitSha?.slice(0, 7)}), but push failed: ${result.error}`,
|
||||
{
|
||||
action: {
|
||||
label: "Retry Push",
|
||||
label: i18n._("Retry Push"),
|
||||
onClick: handlePush,
|
||||
},
|
||||
},
|
||||
@@ -351,7 +366,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("[DiffModal.handleCommitAndPush] Error:", _error);
|
||||
toast.error("Failed to commit and push");
|
||||
toast.error(i18n._("Failed to commit and push"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -370,13 +385,13 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||
<div className="flex flex-col sm:flex-row gap-2 flex-1">
|
||||
<RefSelector
|
||||
label="Compare from"
|
||||
label={i18n._("Compare from")}
|
||||
value={compareFrom}
|
||||
onValueChange={setCompareFrom}
|
||||
refs={gitRefs.filter((ref) => ref.name !== "working")}
|
||||
/>
|
||||
<RefSelector
|
||||
label="Compare to"
|
||||
label={i18n._("Compare to")}
|
||||
value={compareTo}
|
||||
onValueChange={setCompareTo}
|
||||
refs={gitRefs}
|
||||
@@ -395,7 +410,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
{isDiffLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
<Trans id="common.loading" message="Loading..." />
|
||||
</>
|
||||
) : (
|
||||
<RefreshCcwIcon className="w-4 h-4" />
|
||||
@@ -435,133 +450,181 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
|
||||
{/* Commit UI Section */}
|
||||
{compareTo === "working" && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4 space-y-3 border border-gray-200 dark:border-gray-700">
|
||||
{/* File selection controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleSelectAll}
|
||||
disabled={commitMutation.isPending}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={commitMutation.isPending}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedCount} / {diffData.data.files.length} files
|
||||
selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg mb-4 border border-gray-200 dark:border-gray-700">
|
||||
{/* Section header with toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsCommitSectionExpanded(!isCommitSectionExpanded)
|
||||
}
|
||||
className="w-full flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors rounded-t-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Trans id="diff.commit.changes" message="Commit Changes" />
|
||||
</span>
|
||||
{isCommitSectionExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* File list with checkboxes */}
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded p-2">
|
||||
{diffData.data.files.map((file) => (
|
||||
<div
|
||||
key={file.filePath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`file-${file.filePath}`}
|
||||
checked={selectedFiles.get(file.filePath) ?? false}
|
||||
onCheckedChange={() => handleToggleFile(file.filePath)}
|
||||
disabled={commitMutation.isPending}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`file-${file.filePath}`}
|
||||
className="text-sm font-mono cursor-pointer flex-1"
|
||||
>
|
||||
{file.filePath}
|
||||
</label>
|
||||
{/* Collapsible content */}
|
||||
{isCommitSectionExpanded && (
|
||||
<div className="p-4 pt-0 space-y-3">
|
||||
{/* File selection controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleSelectAll}
|
||||
disabled={commitMutation.isPending}
|
||||
>
|
||||
<Trans id="diff.select.all" message="Select All" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={commitMutation.isPending}
|
||||
>
|
||||
<Trans
|
||||
id="diff.deselect.all"
|
||||
message="Deselect All"
|
||||
/>
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedCount} / {diffData.data.files.length} files
|
||||
selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Commit message input */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={commitMessageId}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Commit message
|
||||
</label>
|
||||
<Textarea
|
||||
id={commitMessageId}
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
disabled={commitMutation.isPending}
|
||||
className="resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{/* File list with checkboxes */}
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded p-2">
|
||||
{diffData.data.files.map((file) => (
|
||||
<div
|
||||
key={file.filePath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`file-${file.filePath}`}
|
||||
checked={selectedFiles.get(file.filePath) ?? false}
|
||||
onCheckedChange={() =>
|
||||
handleToggleFile(file.filePath)
|
||||
}
|
||||
disabled={commitMutation.isPending}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`file-${file.filePath}`}
|
||||
className="text-sm font-mono cursor-pointer flex-1"
|
||||
>
|
||||
{file.filePath}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isCommitDisabled}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{commitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
"Commit"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePush}
|
||||
disabled={pushMutation.isPending}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{pushMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Pushing...
|
||||
</>
|
||||
) : (
|
||||
"Push"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommitAndPush}
|
||||
disabled={
|
||||
isCommitDisabled || commitAndPushMutation.isPending
|
||||
}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{commitAndPushMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Committing & Pushing...
|
||||
</>
|
||||
) : (
|
||||
"Commit & Push"
|
||||
)}
|
||||
</Button>
|
||||
{isCommitDisabled &&
|
||||
!commitMutation.isPending &&
|
||||
!commitAndPushMutation.isPending && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{selectedCount === 0
|
||||
? "Select at least one file"
|
||||
: "Enter a commit message"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Commit message input */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={commitMessageId}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Trans
|
||||
id="diff.commit.message"
|
||||
message="Commit message"
|
||||
/>
|
||||
</label>
|
||||
<Textarea
|
||||
id={commitMessageId}
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
disabled={commitMutation.isPending}
|
||||
className="resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isCommitDisabled}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{commitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Trans
|
||||
id="diff.committing"
|
||||
message="Committing..."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Trans id="diff.commit" message="Commit" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePush}
|
||||
disabled={pushMutation.isPending}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{pushMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Trans id="diff.pushing" message="Pushing..." />
|
||||
</>
|
||||
) : (
|
||||
<Trans id="diff.push" message="Push" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommitAndPush}
|
||||
disabled={
|
||||
isCommitDisabled || commitAndPushMutation.isPending
|
||||
}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{commitAndPushMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Trans
|
||||
id="diff.committing.pushing"
|
||||
message="Committing & Pushing..."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Trans
|
||||
id="diff.commit.push"
|
||||
message="Commit & Push"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isCommitDisabled &&
|
||||
!commitMutation.isPending &&
|
||||
!commitAndPushMutation.isPending && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{selectedCount === 0 ? (
|
||||
<Trans
|
||||
id="diff.select.file"
|
||||
message="Select at least one file"
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
id="diff.enter.message"
|
||||
message="Enter a commit message"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -591,7 +654,7 @@ export const DiffModal: FC<DiffModalProps> = ({
|
||||
<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...
|
||||
<Trans id="diff.loading" message="Loading diff..." />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ export const ContinueChat: FC<{
|
||||
sessionId: string;
|
||||
sessionProcessId: string;
|
||||
}> = ({ projectId, sessionId, sessionProcessId }) => {
|
||||
const { i18n } = useLingui();
|
||||
const continueSessionProcess = useContinueSessionProcessMutation(
|
||||
projectId,
|
||||
sessionId,
|
||||
@@ -23,14 +25,22 @@ export const ContinueChat: FC<{
|
||||
const getPlaceholder = () => {
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Enter to send)",
|
||||
);
|
||||
}
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Command+Enter to send)",
|
||||
);
|
||||
}
|
||||
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Shift+Enter to send)",
|
||||
);
|
||||
};
|
||||
|
||||
const buttonText = <Trans id="chat.send" message="Send" />;
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
|
||||
@@ -41,7 +51,7 @@ export const ContinueChat: FC<{
|
||||
isPending={continueSessionProcess.isPending}
|
||||
error={continueSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={"Send"}
|
||||
buttonText={buttonText}
|
||||
minHeight="min-h-[120px]"
|
||||
containerClassName=""
|
||||
buttonSize="lg"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ export const ResumeChat: FC<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
}> = ({ projectId, sessionId }) => {
|
||||
const { i18n } = useLingui();
|
||||
const createSessionProcess = useCreateSessionProcessMutation(projectId);
|
||||
const { config } = useConfig();
|
||||
|
||||
@@ -22,14 +24,22 @@ export const ResumeChat: FC<{
|
||||
const getPlaceholder = () => {
|
||||
const behavior = config?.enterKeyBehavior;
|
||||
if (behavior === "enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Enter to send)",
|
||||
);
|
||||
}
|
||||
if (behavior === "command-enter-send") {
|
||||
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Command+Enter to send)",
|
||||
);
|
||||
}
|
||||
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
return i18n._(
|
||||
"Type your message... (Start with / for commands, @ for files, Shift+Enter to send)",
|
||||
);
|
||||
};
|
||||
|
||||
const buttonText = <Trans id="chat.resume" message="Resume" />;
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
|
||||
@@ -40,7 +50,7 @@ export const ResumeChat: FC<{
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={"Resume"}
|
||||
buttonText={buttonText}
|
||||
minHeight="min-h-[120px]"
|
||||
containerClassName=""
|
||||
buttonSize="lg"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
@@ -8,6 +9,7 @@ import { mcpListQuery } from "../../../../../../../lib/api/queries";
|
||||
|
||||
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const {
|
||||
data: mcpData,
|
||||
@@ -29,7 +31,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
<div className="p-3 border-b border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-sidebar-foreground">
|
||||
MCP Servers
|
||||
<Trans id="mcp.title" message="MCP Servers" />
|
||||
</h2>
|
||||
<Button
|
||||
onClick={handleReload}
|
||||
@@ -37,7 +39,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isLoading}
|
||||
title="Reload MCP servers"
|
||||
title={i18n._("Reload MCP servers")}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
@@ -49,19 +51,25 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Trans id="common.loading" message="Loading..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500">
|
||||
Failed to load MCP servers: {(error as Error).message}
|
||||
<Trans
|
||||
id="mcp.error.load_failed"
|
||||
message="Failed to load MCP servers: {error}"
|
||||
values={{ error: (error as Error).message }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mcpData && mcpData.servers.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
No MCP servers found
|
||||
<Trans id="mcp.no.servers" message="No MCP servers found" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MessageSquareIcon,
|
||||
@@ -37,6 +38,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
const {
|
||||
data: projectData,
|
||||
fetchNextPage,
|
||||
@@ -110,7 +112,10 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-sm text-sidebar-foreground/70">
|
||||
Loading settings...
|
||||
<Trans
|
||||
id="settings.loading"
|
||||
message="Loading settings..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -118,14 +123,20 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Session Display
|
||||
<Trans
|
||||
id="settings.session.display"
|
||||
message="Session Display"
|
||||
/>
|
||||
</h3>
|
||||
<SettingsControls openingProjectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
<Trans
|
||||
id="settings.notifications"
|
||||
message="Notifications"
|
||||
/>
|
||||
</h3>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
@@ -159,7 +170,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={i18n._("Close sidebar")}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -182,7 +193,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
<p>
|
||||
<Trans
|
||||
id="sidebar.back.to.projects"
|
||||
message="Back to projects"
|
||||
/>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -205,7 +221,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>セッション一覧を表示</p>
|
||||
<p>
|
||||
<Trans
|
||||
id="sidebar.show.session.list"
|
||||
message="Show session list"
|
||||
/>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -227,7 +248,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>MCPサーバー設定を表示</p>
|
||||
<p>
|
||||
<Trans
|
||||
id="sidebar.show.mcp.settings"
|
||||
message="Show MCP server settings"
|
||||
/>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -249,7 +275,10 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>表示と通知の設定</p>
|
||||
<Trans
|
||||
id="settings.tab.title"
|
||||
message="Settings for display and notifications"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, useMemo } from "react";
|
||||
@@ -43,7 +44,7 @@ export const SessionSidebar: FC<{
|
||||
{
|
||||
id: "sessions",
|
||||
icon: MessageSquareIcon,
|
||||
title: "セッション一覧を表示",
|
||||
title: "Show session list",
|
||||
content: (
|
||||
<SessionsTab
|
||||
sessions={sessions.map((session) => ({
|
||||
@@ -61,7 +62,7 @@ export const SessionSidebar: FC<{
|
||||
{
|
||||
id: "mcp",
|
||||
icon: PlugIcon,
|
||||
title: "MCPサーバー設定を表示",
|
||||
title: "Show MCP server settings",
|
||||
content: <McpTab projectId={projectId} />,
|
||||
},
|
||||
],
|
||||
@@ -95,7 +96,12 @@ export const SessionSidebar: FC<{
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
<p>
|
||||
<Trans
|
||||
id="sidebar.back.to.projects"
|
||||
message="Back to projects"
|
||||
/>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { MessageSquareIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -7,7 +8,9 @@ import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
|
||||
import type { Session } from "../../../../../../../server/core/types";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
|
||||
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
|
||||
@@ -30,6 +33,7 @@ export const SessionsTab: FC<{
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const sessionProcesses = useAtomValue(sessionProcessesAtom);
|
||||
const { config } = useConfig();
|
||||
|
||||
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
@@ -68,7 +72,9 @@ export const SessionsTab: FC<{
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg">Sessions</h2>
|
||||
<h2 className="font-semibold text-lg">
|
||||
<Trans id="sessions.title" message="Sessions" />
|
||||
</h2>
|
||||
<NewChatModal
|
||||
projectId={projectId}
|
||||
trigger={
|
||||
@@ -83,13 +89,13 @@ export const SessionsTab: FC<{
|
||||
}
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
New
|
||||
<Trans id="sessions.new" message="New" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-sidebar-foreground/70">
|
||||
{sessions.length} total
|
||||
{sessions.length} <Trans id="sessions.total" message="total" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +137,11 @@ export const SessionsTab: FC<{
|
||||
isPaused && "bg-yellow-500 text-white",
|
||||
)}
|
||||
>
|
||||
{isRunning ? "Running" : "Paused"}
|
||||
{isRunning ? (
|
||||
<Trans id="session.status.running" message="Running" />
|
||||
) : (
|
||||
<Trans id="session.status.paused" message="Paused" />
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,13 +152,10 @@ export const SessionsTab: FC<{
|
||||
</div>
|
||||
{session.lastModifiedAt && (
|
||||
<span className="text-xs text-sidebar-foreground/60">
|
||||
{new Date(session.lastModifiedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
{formatLocaleDate(session.lastModifiedAt, {
|
||||
locale: config.locale,
|
||||
target: "time",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,7 +174,11 @@ export const SessionsTab: FC<{
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load More"}
|
||||
{isFetchingNextPage ? (
|
||||
<Trans id="common.loading" message="Loading..." />
|
||||
) : (
|
||||
<Trans id="sessions.load.more" message="Load More" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function SessionErrorPage({
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/projects/${projectId}`)}
|
||||
onClick={() => router.push(`/projects/${projectId}/latest`)}
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
projectDetailQuery,
|
||||
sessionDetailQuery,
|
||||
} from "../../../../../lib/api/queries";
|
||||
import { initializeI18n } from "../../../../../lib/i18n/initializeI18n";
|
||||
import { SessionPageContent } from "./components/SessionPageContent";
|
||||
|
||||
type PageParams = {
|
||||
@@ -41,6 +42,7 @@ interface SessionPageProps {
|
||||
|
||||
export default async function SessionPage({ params }: SessionPageProps) {
|
||||
const { projectId, sessionId } = await params;
|
||||
await initializeI18n();
|
||||
|
||||
return <SessionPageContent projectId={projectId} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -54,17 +55,22 @@ export const CreateProjectDialog: FC = () => {
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="new-project-button">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
<Trans id="project.new" message="New Project" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl" data-testid="new-project-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans id="project.create.title" message="Create New Project" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a directory to initialize as a Claude Code project. This will
|
||||
run{" "}
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">/init</code>{" "}
|
||||
in the selected directory.
|
||||
<Trans
|
||||
id="project.create.description"
|
||||
message="Select a directory to initialize as a Claude Code project. This will run <0>/init</0> in the selected directory."
|
||||
components={{
|
||||
0: <code className="text-sm bg-muted px-1 py-0.5 rounded" />,
|
||||
}}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
@@ -74,7 +80,12 @@ export const CreateProjectDialog: FC = () => {
|
||||
/>
|
||||
{selectedPath ? (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm font-medium mb-1">Selected directory:</p>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
<Trans
|
||||
id="project.create.selected_directory"
|
||||
message="Selected directory:"
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{selectedPath}
|
||||
</p>
|
||||
@@ -83,7 +94,7 @@ export const CreateProjectDialog: FC = () => {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
<Trans id="common.action.cancel" message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => await createProjectMutation.mutateAsync()}
|
||||
@@ -92,10 +103,16 @@ export const CreateProjectDialog: FC = () => {
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
<Trans
|
||||
id="project.create.action.creating"
|
||||
message="Creating..."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
<Trans
|
||||
id="project.create.action.create"
|
||||
message="Create Project"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Folder } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
@@ -34,16 +35,17 @@ export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
|
||||
<div className="border rounded-md">
|
||||
<div className="p-3 border-b bg-muted/50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
Current: <span className="font-mono">{data?.currentPath || "~"}</span>
|
||||
<Trans id="directory_picker.current" message="Current:" />{" "}
|
||||
<span className="font-mono">{data?.currentPath || "~"}</span>
|
||||
</p>
|
||||
<Button size="sm" onClick={handleSelect}>
|
||||
Select This Directory
|
||||
<Trans id="directory_picker.select" message="Select This Directory" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<Trans id="directory_picker.loading" message="Loading..." />
|
||||
</div>
|
||||
) : data?.entries && data.entries.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
@@ -67,7 +69,10 @@ export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
No directories found
|
||||
<Trans
|
||||
id="directory_picker.no_directories"
|
||||
message="No directories found"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { FolderIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
@@ -11,21 +12,31 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { formatLocaleDate } from "../../../lib/date/formatLocaleDate";
|
||||
import { useConfig } from "../../hooks/useConfig";
|
||||
import { useProjects } from "../hooks/useProjects";
|
||||
|
||||
export const ProjectList: FC = () => {
|
||||
const {
|
||||
data: { projects },
|
||||
} = useProjects();
|
||||
const { config } = useConfig();
|
||||
|
||||
if (projects.length === 0) {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FolderIcon className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No projects found</h3>
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
<Trans
|
||||
id="project_list.no_projects.title"
|
||||
message="No projects found"
|
||||
/>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
No Claude Code projects found in your ~/.claude/projects directory.
|
||||
Start a conversation with Claude Code to create your first project.
|
||||
<Trans
|
||||
id="project_list.no_projects.description"
|
||||
message="No Claude Code projects found in your ~/.claude/projects directory. Start a conversation with Claude Code to create your first project."
|
||||
/>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
@@ -48,19 +59,26 @@ export const ProjectList: FC = () => {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last modified:{" "}
|
||||
<Trans id="project_list.last_modified" message="Last modified:" />{" "}
|
||||
{project.lastModifiedAt
|
||||
? new Date(project.lastModifiedAt).toLocaleDateString()
|
||||
? formatLocaleDate(project.lastModifiedAt, {
|
||||
locale: config.locale,
|
||||
target: "time",
|
||||
})
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages: {project.meta.sessionCount}
|
||||
<Trans id="project_list.messages" message="Messages:" />{" "}
|
||||
{project.meta.sessionCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/projects/${project.id}/latest`}>
|
||||
View Conversations
|
||||
<Trans
|
||||
id="project_list.view_conversations"
|
||||
message="View Conversations"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { HistoryIcon } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { GlobalSidebar } from "@/components/GlobalSidebar";
|
||||
import { initializeI18n } from "../../lib/i18n/initializeI18n";
|
||||
import { CreateProjectDialog } from "./components/CreateProjectDialog";
|
||||
import { ProjectList } from "./components/ProjectList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
export default async function ProjectsPage() {
|
||||
await initializeI18n();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">
|
||||
<GlobalSidebar className="hidden md:flex" />
|
||||
@@ -21,22 +23,29 @@ export default function ProjectsPage() {
|
||||
Claude Code Viewer
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse your Claude Code conversation history and project
|
||||
interactions
|
||||
<Trans
|
||||
id="projects.page.description"
|
||||
message="Browse your Claude Code conversation history and project interactions"
|
||||
/>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Your Projects</h2>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Trans id="projects.page.title" message="Your Projects" />
|
||||
</h2>
|
||||
<CreateProjectDialog />
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
Loading projects...
|
||||
<Trans
|
||||
id="projects.page.loading"
|
||||
message="Loading projects..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, Suspense, useState } from "react";
|
||||
@@ -16,7 +17,7 @@ import { SettingsControls } from "./SettingsControls";
|
||||
export interface SidebarTab {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
@@ -38,13 +39,23 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
const settingsTab: SidebarTab = {
|
||||
id: "settings",
|
||||
icon: SettingsIcon,
|
||||
title: "表示と通知の設定",
|
||||
title: (
|
||||
<Trans
|
||||
id="settings.tab.title"
|
||||
message="Settings for display and notifications"
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
<h2 className="font-semibold text-lg">Settings</h2>
|
||||
<h2 className="font-semibold text-lg">
|
||||
<Trans id="settings.title" message="Settings" />
|
||||
</h2>
|
||||
<p className="text-xs text-sidebar-foreground/70">
|
||||
Display and behavior preferences
|
||||
<Trans
|
||||
id="settings.description"
|
||||
message="Display and behavior preferences"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +63,7 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-sm text-sidebar-foreground/70">
|
||||
Loading settings...
|
||||
<Trans id="settings.loading" message="Loading settings..." />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -60,14 +71,20 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Session Display
|
||||
<Trans
|
||||
id="settings.section.session_display"
|
||||
message="Session Display"
|
||||
/>
|
||||
</h3>
|
||||
<SettingsControls openingProjectId={projectId ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
<Trans
|
||||
id="settings.section.notifications"
|
||||
message="Notifications"
|
||||
/>
|
||||
</h3>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { type FC, useCallback, useId } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -88,14 +89,17 @@ export const NotificationSettings: FC<NotificationSettingsProps> = ({
|
||||
onClick={handleTestSound}
|
||||
className="px-3"
|
||||
>
|
||||
テスト
|
||||
<Trans id="notification.test" message="Test" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Claude Code のタスクが完了した時に再生する音を選択してください
|
||||
<Trans
|
||||
id="notification.description"
|
||||
message="Select a sound to play when a task completes"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
PermissionRequest,
|
||||
PermissionResponse,
|
||||
} from "@/types/permissions";
|
||||
import { useConfig } from "../app/hooks/useConfig";
|
||||
import { formatLocaleDate } from "../lib/date/formatLocaleDate";
|
||||
|
||||
interface PermissionDialogProps {
|
||||
permissionRequest: PermissionRequest | null;
|
||||
@@ -34,6 +36,7 @@ export const PermissionDialog = ({
|
||||
}: PermissionDialogProps) => {
|
||||
const [isResponding, setIsResponding] = useState(false);
|
||||
const [isParametersExpanded, setIsParametersExpanded] = useState(false);
|
||||
const { config } = useConfig();
|
||||
|
||||
if (!permissionRequest) return null;
|
||||
|
||||
@@ -124,7 +127,10 @@ export const PermissionDialog = ({
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(permissionRequest.timestamp).toLocaleTimeString()}
|
||||
{formatLocaleDate(permissionRequest.timestamp, {
|
||||
locale: config.locale,
|
||||
target: "time",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTheme } from "next-themes";
|
||||
import { type FC, useCallback, useId } from "react";
|
||||
import { type FC, useId } from "react";
|
||||
import { useConfig } from "@/app/hooks/useConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -12,11 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
configQuery,
|
||||
projectDetailQuery,
|
||||
projectListQuery,
|
||||
} from "../lib/api/queries";
|
||||
import { projectDetailQuery, projectListQuery } from "../lib/api/queries";
|
||||
|
||||
interface SettingsControlsProps {
|
||||
openingProjectId: string;
|
||||
@@ -34,30 +31,25 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
const checkboxId = useId();
|
||||
const enterKeyBehaviorId = useId();
|
||||
const permissionModeId = useId();
|
||||
const localeId = useId();
|
||||
const themeId = useId();
|
||||
const { config, updateConfig } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const onConfigChanged = useCallback(async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: configQuery.queryKey,
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: projectListQuery.queryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: projectDetailQuery(openingProjectId).queryKey,
|
||||
});
|
||||
}, [queryClient, openingProjectId]);
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleHideNoUserMessageChange = async () => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
hideNoUserMessageSession: !config?.hideNoUserMessageSession,
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
updateConfig(newConfig, {
|
||||
onSuccess: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: projectListQuery.queryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnifySameTitleChange = async () => {
|
||||
@@ -65,8 +57,13 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
...config,
|
||||
unifySameTitleSession: !config?.unifySameTitleSession,
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
updateConfig(newConfig, {
|
||||
onSuccess: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: projectDetailQuery(openingProjectId).queryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnterKeyBehaviorChange = async (value: string) => {
|
||||
@@ -78,7 +75,6 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
| "command-enter-send",
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
};
|
||||
|
||||
const handlePermissionModeChange = async (value: string) => {
|
||||
@@ -91,7 +87,18 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
| "plan",
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
};
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
locale: value as "ja" | "en",
|
||||
};
|
||||
updateConfig(newConfig, {
|
||||
onSuccess: async () => {
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -107,13 +114,19 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
htmlFor={checkboxId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Hide sessions without user messages
|
||||
<Trans
|
||||
id="settings.session.hide_no_user_message"
|
||||
message="Hide sessions without user messages"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
Only show sessions that contain user commands or messages
|
||||
<Trans
|
||||
id="settings.session.hide_no_user_message.description"
|
||||
message="Only show sessions that contain user commands or messages"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -128,14 +141,19 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
htmlFor={`${checkboxId}-unify`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Unify sessions with same title
|
||||
<Trans
|
||||
id="settings.session.unify_same_title"
|
||||
message="Unify sessions with same title"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
Show only the latest session when multiple sessions have the same
|
||||
title
|
||||
<Trans
|
||||
id="settings.session.unify_same_title.description"
|
||||
message="Show only the latest session when multiple sessions have the same title"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -145,7 +163,10 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
htmlFor={enterKeyBehaviorId}
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Enter Key Behavior
|
||||
<Trans
|
||||
id="settings.input.enter_key_behavior"
|
||||
message="Enter Key Behavior"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<Select
|
||||
@@ -153,21 +174,35 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
onValueChange={handleEnterKeyBehaviorChange}
|
||||
>
|
||||
<SelectTrigger id={enterKeyBehaviorId} className="w-full">
|
||||
<SelectValue placeholder="Select enter key behavior" />
|
||||
<SelectValue placeholder={i18n._("Select enter key behavior")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shift-enter-send">
|
||||
Shift+Enter to send (default)
|
||||
<Trans
|
||||
id="settings.input.enter_key_behavior.shift_enter"
|
||||
message="Shift+Enter to send (default)"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="enter-send">
|
||||
<Trans
|
||||
id="settings.input.enter_key_behavior.enter"
|
||||
message="Enter to send"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="enter-send">Enter to send</SelectItem>
|
||||
<SelectItem value="command-enter-send">
|
||||
Command+Enter to send
|
||||
<Trans
|
||||
id="settings.input.enter_key_behavior.command_enter"
|
||||
message="Command+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
|
||||
<Trans
|
||||
id="settings.input.enter_key_behavior.description"
|
||||
message="Choose how the Enter key behaves in message input"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,7 +213,7 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
htmlFor={permissionModeId}
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Permission Mode
|
||||
<Trans id="settings.permission.mode" message="Permission Mode" />
|
||||
</label>
|
||||
)}
|
||||
<Select
|
||||
@@ -186,23 +221,76 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
onValueChange={handlePermissionModeChange}
|
||||
>
|
||||
<SelectTrigger id={permissionModeId} className="w-full">
|
||||
<SelectValue placeholder="Select permission mode" />
|
||||
<SelectValue placeholder={i18n._("Select permission mode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (Ask permission)</SelectItem>
|
||||
<SelectItem value="default">
|
||||
<Trans
|
||||
id="settings.permission.mode.default"
|
||||
message="Default (Ask permission)"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="acceptEdits">
|
||||
Accept Edits (Auto-approve file edits)
|
||||
<Trans
|
||||
id="settings.permission.mode.accept_edits"
|
||||
message="Accept Edits (Auto-approve file edits)"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="bypassPermissions">
|
||||
Bypass Permissions (No prompts)
|
||||
<Trans
|
||||
id="settings.permission.mode.bypass_permissions"
|
||||
message="Bypass Permissions (No prompts)"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="plan">
|
||||
<Trans
|
||||
id="settings.permission.mode.plan"
|
||||
message="Plan Mode (Planning only)"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="plan">Plan Mode (Planning only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Control how Claude Code handles permission requests for file
|
||||
operations
|
||||
<Trans
|
||||
id="settings.permission.mode.description"
|
||||
message="Control how Claude Code handles permission requests for file operations"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{showLabels && (
|
||||
<label
|
||||
htmlFor={localeId}
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
<Trans id="settings.locale" message="Language" />
|
||||
</label>
|
||||
)}
|
||||
<Select
|
||||
value={config?.locale || "ja"}
|
||||
onValueChange={handleLocaleChange}
|
||||
>
|
||||
<SelectTrigger id={localeId} className="w-full">
|
||||
<SelectValue placeholder={i18n._("Select language")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ja">
|
||||
<Trans id="settings.locale.ja" message="日本語" />
|
||||
</SelectItem>
|
||||
<SelectItem value="en">
|
||||
<Trans id="settings.locale.en" message="English" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<Trans
|
||||
id="settings.locale.description"
|
||||
message="Choose your preferred language"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -210,22 +298,31 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
<div className="space-y-2">
|
||||
{showLabels && (
|
||||
<label htmlFor={themeId} className="text-sm font-medium leading-none">
|
||||
Theme
|
||||
<Trans id="settings.theme" message="Theme" />
|
||||
</label>
|
||||
)}
|
||||
<Select value={theme || "system"} onValueChange={setTheme}>
|
||||
<SelectTrigger id={themeId} className="w-full">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
<SelectValue placeholder={i18n._("Select theme")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="light">
|
||||
<Trans id="settings.theme.light" message="Light" />
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<Trans id="settings.theme.dark" message="Dark" />
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
<Trans id="settings.theme.system" message="System" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose your preferred color theme
|
||||
<Trans
|
||||
id="settings.theme.description"
|
||||
message="Choose your preferred color theme"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
68
src/lib/date/formatLocaleDate.ts
Normal file
68
src/lib/date/formatLocaleDate.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { format } from "date-fns";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import { ja } from "date-fns/locale/ja";
|
||||
import type { SupportedLocale } from "../i18n/schema";
|
||||
|
||||
export const convertDateFnsLocale = (locale: SupportedLocale) => {
|
||||
switch (locale) {
|
||||
case "ja":
|
||||
return ja;
|
||||
case "en":
|
||||
return enUS;
|
||||
default:
|
||||
locale satisfies never;
|
||||
return enUS;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatLocaleDate = (
|
||||
date: Date | string | number,
|
||||
options: {
|
||||
locale?: SupportedLocale;
|
||||
target?: "month" | "day" | "time";
|
||||
},
|
||||
) => {
|
||||
const { locale = "en", target = "time" } = options;
|
||||
|
||||
const dateObject = typeof date === "string" ? new Date(date) : date;
|
||||
const dateFnsLocale = convertDateFnsLocale(locale);
|
||||
|
||||
const getFormatPattern = (
|
||||
locale: SupportedLocale,
|
||||
target: "month" | "day" | "time",
|
||||
): string => {
|
||||
if (locale === "ja") {
|
||||
switch (target) {
|
||||
case "month":
|
||||
return "yyyy年M月";
|
||||
case "day":
|
||||
return "yyyy年M月d日";
|
||||
case "time":
|
||||
return "yyyy年M月d日 HH:mm";
|
||||
}
|
||||
} else if (locale === "en") {
|
||||
switch (target) {
|
||||
case "month":
|
||||
return "MM/yyyy";
|
||||
case "day":
|
||||
return "MM/dd/yyyy";
|
||||
case "time":
|
||||
return "MM/dd/yyyy HH:mm";
|
||||
}
|
||||
}
|
||||
// default
|
||||
switch (target) {
|
||||
case "month":
|
||||
return "yyyy-MM";
|
||||
case "day":
|
||||
return "yyyy-MM-dd";
|
||||
case "time":
|
||||
return "yyyy-MM-dd HH:mm";
|
||||
}
|
||||
};
|
||||
|
||||
const formatPattern = getFormatPattern(locale, target);
|
||||
return format(dateObject, formatPattern, {
|
||||
locale: dateFnsLocale,
|
||||
});
|
||||
};
|
||||
20
src/lib/i18n/LinguiClientProvider.tsx
Normal file
20
src/lib/i18n/LinguiClientProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type Messages, setupI18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { type FC, type PropsWithChildren, useState } from "react";
|
||||
|
||||
export const LinguiClientProvider: FC<
|
||||
PropsWithChildren<{
|
||||
initialLocale: string;
|
||||
initialMessages: Messages;
|
||||
}>
|
||||
> = ({ children, initialLocale, initialMessages }) => {
|
||||
const [i18n] = useState(() => {
|
||||
return setupI18n({
|
||||
locale: initialLocale,
|
||||
messages: { [initialLocale]: initialMessages },
|
||||
});
|
||||
});
|
||||
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
|
||||
};
|
||||
23
src/lib/i18n/LinguiServerProvider.tsx
Normal file
23
src/lib/i18n/LinguiServerProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { setI18n } from "@lingui/react/server";
|
||||
import { getI18nInstance } from "./index";
|
||||
import { LinguiClientProvider } from "./LinguiClientProvider";
|
||||
import type { SupportedLocale } from "./schema";
|
||||
|
||||
export async function LinguiServerProvider(props: {
|
||||
locale: SupportedLocale;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { children, locale } = props;
|
||||
|
||||
const i18n = getI18nInstance(locale);
|
||||
setI18n(i18n);
|
||||
|
||||
return (
|
||||
<LinguiClientProvider
|
||||
initialLocale={locale}
|
||||
initialMessages={i18n.messages}
|
||||
>
|
||||
{children}
|
||||
</LinguiClientProvider>
|
||||
);
|
||||
}
|
||||
50
src/lib/i18n/index.ts
Normal file
50
src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
|
||||
import { type I18n, type Messages, setupI18n } from "@lingui/core";
|
||||
import type { SupportedLocale } from "./schema";
|
||||
|
||||
const locales: SupportedLocale[] = ["ja", "en"];
|
||||
|
||||
async function loadCatalog(locale: SupportedLocale): Promise<{
|
||||
[k: string]: Messages;
|
||||
}> {
|
||||
const { messages } = await import(`./locales/${locale}/messages`);
|
||||
return {
|
||||
[locale]: messages,
|
||||
};
|
||||
}
|
||||
const catalogs = await Promise.all(locales.map(loadCatalog));
|
||||
|
||||
export const allMessages = catalogs.reduce((acc, oneCatalog) => {
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: size is small
|
||||
return { ...acc, ...oneCatalog };
|
||||
}, {});
|
||||
|
||||
type AllI18nInstances = { [K in SupportedLocale]: I18n };
|
||||
|
||||
export const allI18nInstances = locales.reduce(
|
||||
(acc: Partial<AllI18nInstances>, locale) => {
|
||||
const messages = allMessages[locale] ?? {};
|
||||
const i18n = setupI18n({
|
||||
locale,
|
||||
messages: { [locale]: messages },
|
||||
});
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: size is small
|
||||
return { ...acc, [locale]: i18n };
|
||||
},
|
||||
{},
|
||||
) as AllI18nInstances;
|
||||
|
||||
export const getI18nInstance = (locale: SupportedLocale): I18n => {
|
||||
if (!allI18nInstances[locale]) {
|
||||
console.warn(`No i18n instance found for locale "${locale}"`);
|
||||
}
|
||||
|
||||
const instance = allI18nInstances[locale] ?? allI18nInstances.en;
|
||||
|
||||
if (instance === undefined) {
|
||||
throw new Error(`No i18n instance found for locale "${locale}"`);
|
||||
}
|
||||
|
||||
return instance;
|
||||
};
|
||||
9
src/lib/i18n/initializeI18n.ts
Normal file
9
src/lib/i18n/initializeI18n.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { setI18n } from "@lingui/react/server";
|
||||
import { getUserConfigOnServerComponent } from "../../server/lib/config/getUserConfigOnServerComponent";
|
||||
import { getI18nInstance } from ".";
|
||||
|
||||
export const initializeI18n = async () => {
|
||||
const userConfig = await getUserConfigOnServerComponent();
|
||||
const i18n = getI18nInstance(userConfig.locale);
|
||||
setI18n(i18n);
|
||||
};
|
||||
1354
src/lib/i18n/locales/en/messages.json
Normal file
1354
src/lib/i18n/locales/en/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
4
src/lib/i18n/locales/en/messages.ts
Normal file
4
src/lib/i18n/locales/en/messages.ts
Normal file
File diff suppressed because one or more lines are too long
1354
src/lib/i18n/locales/ja/messages.json
Normal file
1354
src/lib/i18n/locales/ja/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
4
src/lib/i18n/locales/ja/messages.ts
Normal file
4
src/lib/i18n/locales/ja/messages.ts
Normal file
File diff suppressed because one or more lines are too long
4
src/lib/i18n/schema.ts
Normal file
4
src/lib/i18n/schema.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import z from "zod";
|
||||
|
||||
export const localeSchema = z.enum(["ja", "en"]);
|
||||
export type SupportedLocale = z.infer<typeof localeSchema>;
|
||||
@@ -2,6 +2,8 @@
|
||||
* Audio notification utilities for task completion alerts
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { NotificationSoundType } from "./atoms/notifications";
|
||||
|
||||
/**
|
||||
@@ -94,13 +96,15 @@ export function playNotificationSound(soundType: NotificationSoundType) {
|
||||
/**
|
||||
* Get display name for sound types
|
||||
*/
|
||||
export function getSoundDisplayName(soundType: NotificationSoundType): string {
|
||||
const displayNames: Record<NotificationSoundType, string> = {
|
||||
none: "なし",
|
||||
beep: "ビープ",
|
||||
chime: "チャイム",
|
||||
ping: "ピン",
|
||||
pop: "ポップ",
|
||||
export function getSoundDisplayName(
|
||||
soundType: NotificationSoundType,
|
||||
): ReactNode {
|
||||
const displayNames: Record<NotificationSoundType, ReactNode> = {
|
||||
none: <Trans id="notification.none" message="None" />,
|
||||
beep: <Trans id="notification.beep" message="Beep" />,
|
||||
chime: <Trans id="notification.chime" message="Chime" />,
|
||||
ping: <Trans id="notification.ping" message="Ping" />,
|
||||
pop: <Trans id="notification.pop" message="Pop" />,
|
||||
};
|
||||
|
||||
return displayNames[soundType];
|
||||
@@ -8,6 +8,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
unifySameTitleSession: true,
|
||||
enterKeyBehavior: "shift-enter-send",
|
||||
permissionMode: "default",
|
||||
locale: "ja",
|
||||
});
|
||||
|
||||
const setUserConfig = (newConfig: UserConfig) =>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { userConfigSchema } from "../../lib/config/config";
|
||||
import type { UserConfig } from "../../lib/config/config";
|
||||
import { parseUserConfig } from "../../lib/config/parseUserConfig";
|
||||
import type { HonoContext } from "../app";
|
||||
|
||||
export const configMiddleware = createMiddleware<HonoContext>(
|
||||
async (c, next) => {
|
||||
const cookie = getCookie(c, "ccv-config");
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return userConfigSchema.parse(JSON.parse(cookie ?? "{}"));
|
||||
} catch {
|
||||
return userConfigSchema.parse({});
|
||||
}
|
||||
})();
|
||||
const parsed = parseUserConfig(cookie);
|
||||
|
||||
if (cookie === undefined) {
|
||||
setCookie(
|
||||
@@ -21,7 +16,10 @@ export const configMiddleware = createMiddleware<HonoContext>(
|
||||
JSON.stringify({
|
||||
hideNoUserMessageSession: true,
|
||||
unifySameTitleSession: true,
|
||||
}),
|
||||
enterKeyBehavior: "shift-enter-send",
|
||||
permissionMode: "default",
|
||||
locale: "ja",
|
||||
} satisfies UserConfig),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod";
|
||||
import { localeSchema } from "../../../lib/i18n/schema";
|
||||
|
||||
export const userConfigSchema = z.object({
|
||||
hideNoUserMessageSession: z.boolean().optional().default(true),
|
||||
@@ -11,6 +12,7 @@ export const userConfigSchema = z.object({
|
||||
.enum(["acceptEdits", "bypassPermissions", "default", "plan"])
|
||||
.optional()
|
||||
.default("default"),
|
||||
locale: localeSchema.optional().default("en"),
|
||||
});
|
||||
|
||||
export type UserConfig = z.infer<typeof userConfigSchema>;
|
||||
|
||||
8
src/server/lib/config/getUserConfigOnServerComponent.ts
Normal file
8
src/server/lib/config/getUserConfigOnServerComponent.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { parseUserConfig } from "./parseUserConfig";
|
||||
|
||||
export const getUserConfigOnServerComponent = async () => {
|
||||
const cookie = await cookies();
|
||||
const userConfigJson = cookie.get("ccv-config")?.value;
|
||||
return parseUserConfig(userConfigJson);
|
||||
};
|
||||
13
src/server/lib/config/parseUserConfig.ts
Normal file
13
src/server/lib/config/parseUserConfig.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { userConfigSchema } from "./config";
|
||||
|
||||
export const parseUserConfig = (configJson: string | undefined) => {
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return userConfigSchema.parse(JSON.parse(configJson ?? "{}"));
|
||||
} catch {
|
||||
return userConfigSchema.parse({});
|
||||
}
|
||||
})();
|
||||
|
||||
return parsed;
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export const testPlatformLayer = (overrides?: {
|
||||
enterKeyBehavior:
|
||||
overrides?.userConfig?.enterKeyBehavior ?? "shift-enter-send",
|
||||
permissionMode: overrides?.userConfig?.permissionMode ?? "default",
|
||||
locale: overrides?.userConfig?.locale ?? "ja",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user