Merge pull request #23 from d-kimuson/feature/i18n

feat: Add i18n support with English and Japanese
This commit is contained in:
きむそん
2025-10-20 00:21:12 +09:00
committed by GitHub
56 changed files with 5297 additions and 390 deletions

View File

@@ -28,6 +28,9 @@
"recommended": true,
"style": {
"noProcessEnv": "error"
},
"correctness": {
"useUniqueElementIds": "off"
}
}
},

20
lingui.config.ts Normal file
View 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;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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>
</>
) : (
<>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

4
src/lib/i18n/schema.ts Normal file
View File

@@ -0,0 +1,4 @@
import z from "zod";
export const localeSchema = z.enum(["ja", "en"]);
export type SupportedLocale = z.infer<typeof localeSchema>;

View File

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

View File

@@ -8,6 +8,7 @@ const LayerImpl = Effect.gen(function* () {
unifySameTitleSession: true,
enterKeyBehavior: "shift-enter-send",
permissionMode: "default",
locale: "ja",
});
const setUserConfig = (newConfig: UserConfig) =>

View File

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

View File

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

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

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

View File

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