update design

This commit is contained in:
d-kimsuon
2025-10-17 04:37:09 +09:00
parent 21070d09ff
commit 1795cb499b
54 changed files with 4299 additions and 761 deletions

View File

@@ -1,221 +0,0 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ChevronDownIcon,
FolderIcon,
MessageSquareIcon,
PlusIcon,
SettingsIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useConfig } from "../../../hooks/useConfig";
import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle";
import { NewChatModal } from "./newChat/NewChatModal";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useProject(projectId);
const { config } = useConfig();
const queryClient = useQueryClient();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// Flatten all pages to get project and sessions
const project = data.pages.at(0)?.project;
const sessions = data.pages.flatMap((page) => page.sessions);
if (!project) {
throw new Error("Unreachable: Project must be defined.");
}
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
useEffect(() => {
void queryClient.refetchQueries({
queryKey: ["projects", projectId],
});
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
return (
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8 max-w-6xl">
<header className="mb-6 sm:mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link href="/projects" className="flex items-center gap-2">
<ArrowLeftIcon className="w-4 h-4" />
<span className="hidden sm:inline">Back to Projects</span>
<span className="sm:hidden">Back</span>
</Link>
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<FolderIcon className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold break-words overflow-hidden">
{project.meta.projectPath ?? project.claudeProjectPath}
</h1>
</div>
<div className="flex-shrink-0">
<NewChatModal
projectId={projectId}
trigger={
<Button
size="lg"
className="gap-2 w-full sm:w-auto"
data-testid="new-chat"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline">Start New Chat</span>
<span className="sm:hidden">New Chat</span>
</Button>
}
/>
</div>
</div>
<p className="text-muted-foreground font-mono text-xs sm:text-sm break-all">
History File: {project.claudeProjectPath ?? "unknown"}
</p>
</header>
<main>
<section>
<h2 className="text-lg sm:text-xl font-semibold mb-4">
Conversation Sessions{" "}
{sessions.length > 0 ? `(${sessions.length})` : ""}
</h2>
{/* Filter Controls */}
<Collapsible open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
<div className="mb-6">
<CollapsibleTrigger asChild>
<Button
variant="outline"
className="w-full justify-between mb-2 h-auto py-3"
data-testid="expand-filter-settings-button"
>
<div className="flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
<span className="font-medium">Filter Settings</span>
<span className="text-xs text-muted-foreground">
({sessions.length} sessions)
</span>
</div>
<ChevronDownIcon
className={`w-4 h-4 transition-transform ${
isSettingsOpen ? "rotate-180" : ""
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 bg-muted/50 rounded-lg border">
<SettingsControls openingProjectId={projectId} />
</div>
</CollapsibleContent>
</div>
</Collapsible>
{sessions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No sessions found</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
No conversation sessions found for this project. Start a
conversation with Claude Code in this project to create
sessions.
</p>
<NewChatModal
projectId={projectId}
trigger={
<Button size="lg" className="gap-2">
<PlusIcon className="w-5 h-5" />
Start First Chat
</Button>
}
/>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
{sessions.map((session) => (
<Card
key={session.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="break-words overflow-ellipsis line-clamp-2 text-lg sm:text-xl">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: session.id}
</span>
</CardTitle>
<CardDescription className="font-mono text-xs">
{session.id}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{session.meta.messageCount} messages
</p>
<p className="text-sm text-muted-foreground">
Last modified:{" "}
{session.lastModifiedAt
? new Date(session.lastModifiedAt).toLocaleDateString()
: ""}
</p>
<p className="text-xs text-muted-foreground font-mono">
{session.jsonlFilePath}
</p>
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id,
)}`}
>
View Session
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Load More Button */}
{sessions.length > 0 && hasNextPage && (
<div className="mt-6 flex justify-center">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="lg"
className="min-w-[200px]"
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
</div>
)}
</section>
</main>
</div>
);
};

View File

@@ -1,4 +1,9 @@
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import {
AlertCircleIcon,
LoaderIcon,
SendIcon,
SparklesIcon,
} from "lucide-react";
import { type FC, useCallback, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
@@ -62,16 +67,28 @@ export const ChatInput: FC<ChatInputProps> = ({
// IMEで変換中の場合は送信しない
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
const enterKeyBehavior = config?.enterKeyBehavior;
if (isEnterSend && !e.shiftKey) {
if (enterKeyBehavior === "enter-send" && !e.shiftKey && !e.metaKey) {
// Enter: Send mode
e.preventDefault();
handleSubmit();
} else if (!isEnterSend && e.shiftKey) {
} else if (
enterKeyBehavior === "shift-enter-send" &&
e.shiftKey &&
!e.metaKey
) {
// Shift+Enter: Send mode (default)
e.preventDefault();
handleSubmit();
} else if (
enterKeyBehavior === "command-enter-send" &&
e.metaKey &&
!e.shiftKey
) {
// Command+Enter: Send mode (Mac)
e.preventDefault();
handleSubmit();
}
}
};
@@ -148,78 +165,98 @@ export const ChatInput: FC<ChatInputProps> = ({
return (
<div className={containerClassName}>
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md mb-4">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to send message. Please try again.</span>
<div 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.
</span>
</div>
)}
<div className="space-y-3">
<div className="relative" ref={containerRef}>
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => {
if (
e.target.value.endsWith("@") ||
e.target.value.endsWith("/")
) {
const position = getCursorPosition();
if (position) {
setCursorPosition(position);
<div className="relative group">
<div
className="absolute -inset-0.5 bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-pink-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition-opacity duration-500"
aria-hidden="true"
/>
<div className="relative bg-background border border-border/40 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
<div className="relative" ref={containerRef}>
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => {
if (
e.target.value.endsWith("@") ||
e.target.value.endsWith("/")
) {
const position = getCursorPosition();
if (position) {
setCursorPosition(position);
}
}
}
setMessage(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${minHeight} resize-none`}
disabled={isPending || disabled}
maxLength={4000}
aria-label="Message input with completion support"
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<InlineCompletion
projectId={projectId}
message={message}
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
cursorPosition={cursorPosition}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000 characters " • Use arrow keys to navigate
completions"
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled}
size={buttonSize}
className="gap-2"
>
{isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : (
<>
<SendIcon className="w-4 h-4" />
{buttonText}
</>
)}
</Button>
setMessage(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${minHeight} resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent px-5 py-4 text-base transition-all duration-200 placeholder:text-muted-foreground/60`}
disabled={isPending || disabled}
maxLength={4000}
aria-label="Message input with completion support"
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
</div>
<div className="flex items-center justify-between gap-3 px-5 py-3 bg-muted/30 border-t border-border/40">
<div className="flex items-center gap-2">
<span
className="text-xs font-medium text-muted-foreground/80"
id={helpId}
>
{message.length}
<span className="text-muted-foreground/50">/4000</span>
</span>
{(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
</span>
)}
</div>
<Button
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled}
size={buttonSize}
className="gap-2 transition-all duration-200 hover:shadow-md hover:scale-105 active:scale-95 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 disabled:from-muted disabled:to-muted"
>
{isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
<span>Processing...</span>
</>
) : (
<>
<SendIcon className="w-4 h-4" />
{buttonText}
</>
)}
</Button>
</div>
</div>
<InlineCompletion
projectId={projectId}
message={message}
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
cursorPosition={cursorPosition}
/>
</div>
</div>
);

View File

@@ -185,49 +185,53 @@ export const CommandCompletion = forwardRef<
<CollapsibleContent>
<div
ref={listRef}
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
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"
>
{filteredCommands.length > 0 && (
<div className="p-1">
<div
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
role="presentation"
>
<TerminalIcon className="w-3 h-3" />
Available Commands ({filteredCommands.length})
</div>
{filteredCommands.map((command, index) => (
<Button
key={command}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
<div className="h-full overflow-y-auto">
{filteredCommands.length > 0 && (
<div className="p-1.5">
<div
className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
role="presentation"
>
<span className="text-muted-foreground mr-1 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}
</div>
)}
<TerminalIcon className="w-3.5 h-3.5" />
Available Commands ({filteredCommands.length})
</div>
{filteredCommands.map((command, index) => (
<Button
key={command}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
index === selectedIndex
? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
: "hover:bg-accent/50",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
>
<span className="text-muted-foreground mr-1.5 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -259,63 +259,67 @@ export const FileCompletion = forwardRef<
<CollapsibleContent>
<div
ref={listRef}
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
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"
>
{filteredEntries.length > 0 && (
<div className="p-1">
<div
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
role="presentation"
>
<FileIcon className="w-3 h-3" />
Files & Directories ({filteredEntries.length})
{basePath !== "/" && (
<span className="text-xs font-mono text-muted-foreground/70">
in {basePath}
</span>
)}
</div>
{filteredEntries.map((entry, index) => (
<Button
key={entry.path}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
<div className="h-full overflow-y-auto">
{filteredEntries.length > 0 && (
<div className="p-1.5">
<div
className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
role="presentation"
>
{entry.type === "directory" ? (
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" />
) : (
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" />
)}
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
<FileIcon className="w-3.5 h-3.5" />
Files & Directories ({filteredEntries.length})
{basePath !== "/" && (
<span className="text-xs font-mono text-muted-foreground/70">
in {basePath}
</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
{filteredEntries.map((entry, index) => (
<Button
key={entry.path}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
index === selectedIndex
? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
: "hover:bg-accent/50",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
>
{entry.type === "directory" ? (
<FolderIcon className="w-3.5 h-3.5 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0" />
) : (
<FileIcon className="w-3.5 h-3.5 mr-2 text-gray-500 dark:text-gray-400 flex-shrink-0" />
)}
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -15,13 +15,21 @@ interface PositionStyle {
const calculateOptimalPosition = (
relativeCursorPosition: { top: number; left: number },
absoluteCursorPosition: { top: number; left: number },
itemCount: number,
): PositionStyle => {
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 800;
const viewportCenter = viewportHeight / 2;
// Estimated completion height (we'll measure actual height later if needed)
const estimatedCompletionHeight = 200;
// Calculate dynamic height based on item count
// Header: ~48px, Each item: 36px (h-9), Padding: 12px
const headerHeight = 48;
const itemHeight = 36;
const padding = 12;
const maxItems = 5;
const visibleItems = Math.min(itemCount, maxItems);
const estimatedCompletionHeight =
headerHeight + itemHeight * visibleItems + padding;
// Determine preferred placement based on viewport position
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
@@ -33,22 +41,22 @@ const calculateOptimalPosition = (
let placement: "above" | "below";
let top: number;
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) {
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight + 20) {
// Cursor in upper half and enough space below - place below
placement = "below";
top = relativeCursorPosition.top + 16;
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) {
top = relativeCursorPosition.top + 24;
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight + 20) {
// Cursor in lower half and enough space above - place above
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
} else {
// Use whichever side has more space
if (spaceBelow > spaceAbove) {
placement = "below";
top = relativeCursorPosition.top + 16;
top = relativeCursorPosition.top + 24;
} else {
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
}
}
@@ -93,6 +101,7 @@ export const InlineCompletion: FC<{
return calculateOptimalPosition(
cursorPosition.relative,
cursorPosition.absolute,
5,
);
}, [cursorPosition]);

View File

@@ -17,10 +17,13 @@ export const NewChat: FC<{
};
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "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 "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
};
@@ -33,7 +36,7 @@ export const NewChat: FC<{
placeholder={getPlaceholder()}
buttonText="Start Chat"
minHeight="min-h-[200px]"
containerClassName="space-y-4"
containerClassName="p-6"
/>
);
};

View File

@@ -0,0 +1,67 @@
"use client";
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function ProjectErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Failed to load project</CardTitle>
<CardDescription>
We encountered an error while loading this project
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>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}
</div>
)}
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
</Button>
<Button onClick={() => router.push("/projects")} variant="outline">
<ArrowLeft />
Back to Projects
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { QueryClient } from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { latestSessionQuery } from "../../../../lib/api/queries";
interface LatestSessionPageProps {
params: Promise<{ projectId: string }>;
}
export default async function LatestSessionPage({
params,
}: LatestSessionPageProps) {
const { projectId } = await params;
const queryClient = new QueryClient();
const { latestSession } = await queryClient.fetchQuery(
latestSessionQuery(projectId),
);
if (!latestSession) {
redirect(`/projects`);
}
redirect(
`/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(latestSession.id)}`,
);
}

View File

@@ -0,0 +1,42 @@
import { FolderSearch, Home } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function ProjectNotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<FolderSearch className="size-6 text-muted-foreground" />
<div>
<CardTitle>Project Not Found</CardTitle>
<CardDescription>
The project you are looking for does not exist or has been
removed
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="default">
<Link href="/projects">
<Home />
Back to Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,10 +1,4 @@
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { projectDetailQuery } from "../../../lib/api/queries";
import { ProjectPageContent } from "./components/ProjectPage";
import { redirect } from "next/navigation";
interface ProjectPageProps {
params: Promise<{ projectId: string }>;
@@ -12,20 +6,5 @@ interface ProjectPageProps {
export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params;
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ["projects", projectId],
queryFn: async ({ pageParam }) => {
return await projectDetailQuery(projectId, pageParam).queryFn();
},
initialPageParam: undefined as string | undefined,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectPageContent projectId={projectId} />
</HydrationBoundary>
);
redirect(`/projects/${encodeURIComponent(projectId)}/latest`);
}

View File

@@ -2,14 +2,12 @@
import { useMutation } from "@tanstack/react-query";
import {
ExternalLinkIcon,
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
@@ -97,7 +95,7 @@ export const SessionPageContent: FC<{
]);
return (
<div className="flex h-screen max-h-screen overflow-hidden">
<>
<SessionSidebar
currentSessionId={sessionId}
projectId={projectId}
@@ -127,19 +125,12 @@ export const SessionPageContent: FC<{
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && (
<Link
href={`/projects/${projectId}`}
target="_blank"
className="transition-all duration-200"
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
>
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
</Link>
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
<Badge
variant="secondary"
@@ -150,12 +141,18 @@ export const SessionPageContent: FC<{
</div>
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<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...
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div>
<Button
variant="ghost"
@@ -163,18 +160,23 @@ export const SessionPageContent: FC<{
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
>
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span>
</Button>
</div>
)}
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<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">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
Conversation is paused...
</p>
</div>
@@ -184,8 +186,14 @@ export const SessionPageContent: FC<{
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
>
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span>
</Button>
</div>
@@ -204,16 +212,13 @@ export const SessionPageContent: FC<{
/>
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8">
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
</div>
<div className="relative">
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium">
<p className="text-sm text-muted-foreground font-medium animate-pulse">
Claude Code is processing...
</p>
</div>
@@ -255,6 +260,6 @@ export const SessionPageContent: FC<{
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</div>
</>
);
};

View File

@@ -1,8 +1,14 @@
"use client";
import { ChevronDown, Lightbulb, Settings } from "lucide-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import type { FC } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -24,6 +30,8 @@ export const AssistantConversationContent: FC<{
content: AssistantMessageContent;
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
}> = ({ content, getToolResult }) => {
const { resolvedTheme } = useTheme();
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
if (content.type === "text") {
return (
<div className="w-full mx-1 sm:mx-2 my-4 sm:my-6">
@@ -34,14 +42,16 @@ export const AssistantConversationContent: FC<{
if (content.type === "thinking") {
return (
<Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2">
<Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2 hover:shadow-sm transition-all duration-200">
<Collapsible>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-colors py-0 px-4">
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-all duration-200 py-0 px-4 group">
<div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Thinking</CardTitle>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<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
</CardTitle>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</CardHeader>
</CollapsibleTrigger>
@@ -80,16 +90,16 @@ export const AssistantConversationContent: FC<{
<CardContent className="space-y-2 py-0 px-4">
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
<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
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<SyntaxHighlighter
style={oneLight}
style={syntaxTheme}
language="json"
PreTag="div"
className="text-xs"
@@ -101,11 +111,11 @@ export const AssistantConversationContent: FC<{
{toolResult && (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
<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
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>

View File

@@ -143,7 +143,7 @@ export const ConversationList: FC<ConversationListProps> = ({
conversation.type === "summary"
? "justify-start"
: "justify-end"
}`}
} animate-in fade-in slide-in-from-bottom-2 duration-300`}
key={getConversationKey(conversation)}
>
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]">

View File

@@ -88,7 +88,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
return (
<MarkdownContent
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50"
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50 dark:bg-slate-900/50"
content={parsed.content}
/>
);

View File

@@ -21,26 +21,32 @@ export const ContinueChat: FC<{
};
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
return "Type your message... (Start with / for commands, Enter to send)";
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
}
return "Type your message... (Start with / for commands, Shift+Enter to send)";
if (behavior === "command-enter-send") {
return "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 (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={continueSessionProcess.isPending}
error={continueSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Send"}
minHeight="min-h-[100px]"
containerClassName="space-y-2"
buttonSize="default"
/>
<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" />
<div className="pt-8">
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={continueSessionProcess.isPending}
error={continueSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Send"}
minHeight="min-h-[120px]"
containerClassName=""
buttonSize="default"
/>
</div>
</div>
);
};

View File

@@ -20,26 +20,32 @@ export const ResumeChat: FC<{
};
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
return "Type your message... (Start with / for commands, Enter to send)";
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
}
return "Type your message... (Start with / for commands, Shift+Enter to send)";
if (behavior === "command-enter-send") {
return "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 (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Resume"}
minHeight="min-h-[100px]"
containerClassName="space-y-2"
buttonSize="default"
/>
<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" />
<div className="pt-8">
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Resume"}
minHeight="min-h-[120px]"
containerClassName=""
buttonSize="default"
/>
</div>
</div>
);
};

View File

@@ -1,14 +1,15 @@
"use client";
import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { type FC, Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
import { SessionsTab } from "./SessionsTab";
import { SettingsTab } from "./SettingsTab";
interface MobileSidebarProps {
currentSessionId: string;
@@ -89,7 +90,35 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "mcp":
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
return (
<div className="h-full flex flex-col">
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-sm text-sidebar-foreground/70">
Loading settings...
</div>
</div>
}
>
<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
</h3>
<SettingsControls openingProjectId={projectId} />
</div>
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</Suspense>
</div>
);
default:
return null;
}

View File

@@ -1,19 +1,14 @@
"use client";
import {
MessageSquareIcon,
PlugIcon,
SettingsIcon,
Undo2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useState } from "react";
import { MessageSquareIcon, PlugIcon } from "lucide-react";
import { type FC, useMemo } from "react";
import type { SidebarTab } from "@/components/GlobalSidebar";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
import { MobileSidebar } from "./MobileSidebar";
import { SessionsTab } from "./SessionsTab";
import { SettingsTab } from "./SettingsTab";
export const SessionSidebar: FC<{
currentSessionId: string;
@@ -28,7 +23,6 @@ export const SessionSidebar: FC<{
isMobileOpen = false,
onMobileOpenChange,
}) => {
const router = useRouter();
const {
data: projectData,
fetchNextPage,
@@ -36,26 +30,14 @@ export const SessionSidebar: FC<{
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
"sessions",
);
const [isExpanded, setIsExpanded] = useState(true);
const handleTabClick = (tab: "sessions" | "mcp" | "settings") => {
if (activeTab === tab && isExpanded) {
// If clicking the active tab while expanded, collapse
setIsExpanded(false);
} else {
// If clicking a different tab or expanding, show that tab
setActiveTab(tab);
setIsExpanded(true);
}
};
const renderContent = () => {
switch (activeTab) {
case "sessions":
return (
const additionalTabs: SidebarTab[] = useMemo(
() => [
{
id: "sessions",
icon: MessageSquareIcon,
title: "Sessions",
content: (
<SessionsTab
sessions={sessions.map((session) => ({
...session,
@@ -67,105 +49,34 @@ export const SessionSidebar: FC<{
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
);
case "mcp":
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
default:
return null;
}
};
const sidebarContent = (
<div
className={cn(
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out flex bg-sidebar text-sidebar-foreground",
isExpanded ? "w-72 lg:w-80" : "w-12",
)}
>
{/* Vertical Icon Menu - Always Visible */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
<div className="flex flex-col p-2 space-y-1">
<button
type="button"
onClick={() => {
router.push(`/projects/${projectId}`);
}}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"text-sidebar-foreground/70",
)}
title="Back to Project"
>
<Undo2Icon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("sessions")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "sessions" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="Sessions"
data-testid="sessions-tab-button"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("mcp")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "mcp" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="MCP Servers"
data-testid="mcp-tab-button"
>
<PlugIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("settings")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "settings" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="Settings"
data-testid="settings-tab-button"
>
<SettingsIcon className="w-4 h-4" />
</button>
</div>
</div>
{/* Content Area - Only shown when expanded */}
{isExpanded && (
<div className="flex-1 flex flex-col overflow-hidden">
{renderContent()}
</div>
)}
</div>
),
},
{
id: "mcp",
icon: PlugIcon,
title: "MCP Servers",
content: <McpTab projectId={projectId} />,
},
],
[
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
],
);
return (
<>
{/* Desktop sidebar */}
<div className={cn("hidden md:flex h-full", className)}>
{sidebarContent}
<GlobalSidebar
projectId={projectId}
additionalTabs={additionalTabs}
defaultActiveTab="sessions"
/>
</div>
{/* Mobile sidebar */}

View File

@@ -103,9 +103,9 @@ export const SessionsTab: FC<{
session.id,
)}`}
className={cn(
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 dark:hover:bg-blue-950/40 hover:border-blue-300/60 dark:hover:border-blue-700/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
isActive &&
"bg-blue-100 border-blue-400 shadow-md ring-1 ring-blue-200/50 hover:bg-blue-100 hover:border-blue-400",
"bg-blue-100 dark:bg-blue-900/50 border-blue-400 dark:border-blue-600 shadow-md ring-1 ring-blue-200/50 dark:ring-blue-700/50 hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:border-blue-400 dark:hover:border-blue-600",
)}
>
<div className="space-y-1.5">

View File

@@ -1,40 +0,0 @@
"use client";
import type { FC } from "react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
export const SettingsTab: FC<{
openingProjectId: string;
}> = ({ openingProjectId }) => {
return (
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
<h2 className="font-semibold text-lg">Settings</h2>
<p className="text-xs text-sidebar-foreground/70">
Display and behavior preferences
</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Session Display Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Session Display
</h3>
<SettingsControls openingProjectId={openingProjectId} />
</div>
{/* Notification Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,72 @@
"use client";
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function SessionErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Failed to load session</CardTitle>
<CardDescription>
We encountered an error while loading this conversation session
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>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}
</div>
)}
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
</Button>
<Button
onClick={() => router.push(`/projects/${projectId}`)}
variant="outline"
>
<ArrowLeft />
Back to Project
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import type { FC, ReactNode } from "react";
interface SessionLayoutProps {
children: ReactNode;
}
const SessionLayout: FC<SessionLayoutProps> = ({ children }) => {
return (
<div className="flex h-screen max-h-screen overflow-hidden">{children}</div>
);
};
export default SessionLayout;

View File

@@ -0,0 +1,42 @@
import { MessageCircleOff } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function SessionNotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<MessageCircleOff className="size-6 text-muted-foreground" />
<div>
<CardTitle>Session Not Found</CardTitle>
<CardDescription>
The conversation session you are looking for does not exist or
has been removed
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="default">
<Link href="/projects">
<MessageCircleOff />
Back to Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -59,8 +59,8 @@ export const ProjectList: FC = () => {
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link href={`/projects/${encodeURIComponent(project.id)}`}>
View Sessions
<Link href={`/projects/${encodeURIComponent(project.id)}/latest`}>
View Conversations
</Link>
</Button>
</CardContent>

View File

@@ -1,38 +1,48 @@
import { QueryClient } from "@tanstack/react-query";
"use client";
import { HistoryIcon } from "lucide-react";
import { projectListQuery } from "../../lib/api/queries";
import { Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export default async function ProjectsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: projectListQuery.queryKey,
queryFn: projectListQuery.queryFn,
});
export default function ProjectsPage() {
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<HistoryIcon className="w-8 h-8" />
Claude Code Viewer
</h1>
<p className="text-muted-foreground">
Browse your Claude Code conversation history and project interactions
</p>
</header>
<div className="flex h-screen max-h-screen overflow-hidden">
<GlobalSidebar className="hidden md:flex" />
<div className="flex-1 overflow-auto">
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<HistoryIcon className="w-8 h-8" />
Claude Code Viewer
</h1>
<p className="text-muted-foreground">
Browse your Claude Code conversation history and project
interactions
</p>
</header>
<main>
<section>
<h2 className="text-xl font-semibold mb-4">Your Projects</h2>
<ProjectList />
</section>
</main>
<main>
<section>
<h2 className="text-xl font-semibold mb-4">Your Projects</h2>
<Suspense
fallback={
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
Loading projects...
</div>
</div>
}
>
<ProjectList />
</Suspense>
</section>
</main>
</div>
</div>
</div>
);
}