mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-09 16:44:23 +01:00
implement frontend
This commit is contained in:
@@ -8,3 +8,5 @@ export {
|
||||
useContinueSessionProcessMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "./useChatMutations";
|
||||
export type { UseMessageCompletionResult } from "./useMessageCompletion";
|
||||
export { useMessageCompletion } from "./useMessageCompletion";
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { CommandCompletionRef } from "./CommandCompletion";
|
||||
import type { FileCompletionRef } from "./FileCompletion";
|
||||
|
||||
export interface UseMessageCompletionResult {
|
||||
cursorPosition: {
|
||||
relative: { top: number; left: number };
|
||||
absolute: { top: number; left: number };
|
||||
};
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
commandCompletionRef: React.RefObject<CommandCompletionRef | null>;
|
||||
fileCompletionRef: React.RefObject<FileCompletionRef | null>;
|
||||
getCursorPosition: () =>
|
||||
| {
|
||||
relative: { top: number; left: number };
|
||||
absolute: { top: number; left: number };
|
||||
}
|
||||
| undefined;
|
||||
handleChange: (value: string, onChange: (value: string) => void) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => boolean;
|
||||
handleCommandSelect: (
|
||||
command: string,
|
||||
onSelect: (command: string) => void,
|
||||
) => void;
|
||||
handleFileSelect: (
|
||||
filePath: string,
|
||||
onSelect: (filePath: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message input with command and file completion support
|
||||
*/
|
||||
export function useMessageCompletion(): UseMessageCompletionResult {
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
relative: { top: number; left: number };
|
||||
absolute: { top: number; left: number };
|
||||
}>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const commandCompletionRef = useRef<CommandCompletionRef>(null);
|
||||
const fileCompletionRef = useRef<FileCompletionRef>(null);
|
||||
|
||||
const getCursorPosition = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const container = containerRef.current;
|
||||
if (textarea === null || container === null) return undefined;
|
||||
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
||||
const textAfterCursor = textarea.value.substring(cursorPos);
|
||||
|
||||
const pre = document.createTextNode(textBeforeCursor);
|
||||
const post = document.createTextNode(textAfterCursor);
|
||||
const caret = document.createElement("span");
|
||||
caret.innerHTML = " ";
|
||||
|
||||
const mirrored = document.createElement("div");
|
||||
|
||||
mirrored.innerHTML = "";
|
||||
mirrored.append(pre, caret, post);
|
||||
|
||||
const textareaStyles = window.getComputedStyle(textarea);
|
||||
for (const property of [
|
||||
"border",
|
||||
"boxSizing",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"padding",
|
||||
"textDecoration",
|
||||
"textIndent",
|
||||
"textTransform",
|
||||
"whiteSpace",
|
||||
"wordSpacing",
|
||||
"wordWrap",
|
||||
] as const) {
|
||||
mirrored.style[property] = textareaStyles[property];
|
||||
}
|
||||
|
||||
mirrored.style.visibility = "hidden";
|
||||
container.prepend(mirrored);
|
||||
|
||||
const caretRect = caret.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
container.removeChild(mirrored);
|
||||
|
||||
return {
|
||||
relative: {
|
||||
top: caretRect.top - containerRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - containerRect.left - textarea.scrollLeft,
|
||||
},
|
||||
absolute: {
|
||||
top: caretRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - textarea.scrollLeft,
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string, onChange: (value: string) => void) => {
|
||||
if (value.endsWith("@") || value.endsWith("/")) {
|
||||
const position = getCursorPosition();
|
||||
if (position) {
|
||||
setCursorPosition(position);
|
||||
}
|
||||
}
|
||||
onChange(value);
|
||||
},
|
||||
[getCursorPosition],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
if (fileCompletionRef.current?.handleKeyDown(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (commandCompletionRef.current?.handleKeyDown(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: string, onSelect: (command: string) => void) => {
|
||||
onSelect(command);
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(filePath: string, onSelect: (filePath: string) => void) => {
|
||||
onSelect(filePath);
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
cursorPosition,
|
||||
containerRef,
|
||||
textareaRef,
|
||||
commandCompletionRef,
|
||||
fileCompletionRef,
|
||||
getCursorPosition,
|
||||
handleChange,
|
||||
handleKeyDown,
|
||||
handleCommandSelect,
|
||||
handleFileSelect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import { EditIcon, PlusIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SchedulerJobDialog } from "@/components/scheduler/SchedulerJobDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
type NewSchedulerJob,
|
||||
type SchedulerJob,
|
||||
useCreateSchedulerJob,
|
||||
useDeleteSchedulerJob,
|
||||
useSchedulerJobs,
|
||||
useUpdateSchedulerJob,
|
||||
} from "@/hooks/useScheduler";
|
||||
|
||||
export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
|
||||
projectId,
|
||||
sessionId,
|
||||
}) => {
|
||||
const { i18n } = useLingui();
|
||||
const { data: jobs, isLoading, error, refetch } = useSchedulerJobs();
|
||||
const createJob = useCreateSchedulerJob();
|
||||
const updateJob = useUpdateSchedulerJob();
|
||||
const deleteJob = useDeleteSchedulerJob();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<SchedulerJob | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletingJobId, setDeletingJobId] = useState<string | null>(null);
|
||||
|
||||
const handleCreateJob = (job: NewSchedulerJob) => {
|
||||
createJob.mutate(job, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
i18n._({
|
||||
id: "scheduler.job.created",
|
||||
message: "Job created successfully",
|
||||
}),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
i18n._({
|
||||
id: "scheduler.job.create_failed",
|
||||
message: "Failed to create job",
|
||||
}),
|
||||
{
|
||||
description: error.message,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateJob = (job: NewSchedulerJob) => {
|
||||
if (!editingJob) return;
|
||||
|
||||
updateJob.mutate(
|
||||
{
|
||||
id: editingJob.id,
|
||||
updates: job,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
i18n._({
|
||||
id: "scheduler.job.updated",
|
||||
message: "Job updated successfully",
|
||||
}),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setEditingJob(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
i18n._({
|
||||
id: "scheduler.job.update_failed",
|
||||
message: "Failed to update job",
|
||||
}),
|
||||
{
|
||||
description: error.message,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deletingJobId) return;
|
||||
|
||||
deleteJob.mutate(deletingJobId, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
i18n._({
|
||||
id: "scheduler.job.deleted",
|
||||
message: "Job deleted successfully",
|
||||
}),
|
||||
);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingJobId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
i18n._({
|
||||
id: "scheduler.job.delete_failed",
|
||||
message: "Failed to delete job",
|
||||
}),
|
||||
{
|
||||
description: error.message,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditClick = (job: SchedulerJob) => {
|
||||
setEditingJob(job);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (jobId: string) => {
|
||||
setDeletingJobId(jobId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const formatSchedule = (job: SchedulerJob) => {
|
||||
if (job.schedule.type === "cron") {
|
||||
return `Cron: ${job.schedule.expression}`;
|
||||
}
|
||||
const hours = Math.floor(job.schedule.delayMs / 3600000);
|
||||
const minutes = Math.floor((job.schedule.delayMs % 3600000) / 60000);
|
||||
const timeStr =
|
||||
hours > 0
|
||||
? `${hours}h ${minutes}m`
|
||||
: minutes > 0
|
||||
? `${minutes}m`
|
||||
: `${job.schedule.delayMs}ms`;
|
||||
return `${job.schedule.oneTime ? "Once" : "Recurring"}: ${timeStr}`;
|
||||
};
|
||||
|
||||
const formatLastRun = (lastRunAt: string | null) => {
|
||||
if (!lastRunAt) return "Never";
|
||||
const date = new Date(lastRunAt);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-sidebar-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-sidebar-foreground">
|
||||
<Trans id="scheduler.title" message="Scheduler" />
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isLoading}
|
||||
title={i18n._({ id: "common.reload", message: "Reload" })}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingJob(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
title={i18n._({
|
||||
id: "scheduler.create_job",
|
||||
message: "Create Job",
|
||||
})}
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Trans id="common.loading" message="Loading..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500">
|
||||
<Trans
|
||||
id="scheduler.error.load_failed"
|
||||
message="Failed to load scheduler jobs: {error}"
|
||||
values={{ error: error.message }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobs && jobs.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
<Trans
|
||||
id="scheduler.no_jobs"
|
||||
message="No scheduled jobs. Click + to create one."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobs && jobs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="p-3 bg-sidebar-accent/50 rounded-md border border-sidebar-border"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-sidebar-foreground truncate">
|
||||
{job.name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={job.enabled ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{job.enabled ? (
|
||||
<Trans
|
||||
id="scheduler.status.enabled"
|
||||
message="Enabled"
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
id="scheduler.status.disabled"
|
||||
message="Disabled"
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatSchedule(job)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleEditClick(job)}
|
||||
>
|
||||
<EditIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteClick(job.id)}
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.lastRunAt && (
|
||||
<div className="text-xs text-muted-foreground mt-2 pt-2 border-t border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<Trans id="scheduler.last_run" message="Last run: " />
|
||||
<span>{formatLastRun(job.lastRunAt)}</span>
|
||||
</span>
|
||||
{job.lastRunStatus && (
|
||||
<Badge
|
||||
variant={
|
||||
job.lastRunStatus === "success"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{job.lastRunStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<SchedulerJobDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setEditingJob(null);
|
||||
}}
|
||||
job={editingJob}
|
||||
projectId={projectId}
|
||||
currentSessionId={sessionId}
|
||||
onSubmit={editingJob ? handleUpdateJob : handleCreateJob}
|
||||
isSubmitting={createJob.isPending || updateJob.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans id="scheduler.delete_dialog.title" message="Delete Job" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
id="scheduler.delete_dialog.description"
|
||||
message="Are you sure you want to delete this job? This action cannot be undone."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingJobId(null);
|
||||
}}
|
||||
disabled={deleteJob.isPending}
|
||||
>
|
||||
<Trans id="common.cancel" message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteJob.isPending}
|
||||
>
|
||||
{deleteJob.isPending ? (
|
||||
<Trans id="common.deleting" message="Deleting..." />
|
||||
) : (
|
||||
<Trans id="common.delete" message="Delete" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarClockIcon,
|
||||
MessageSquareIcon,
|
||||
PlugIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, useMemo } from "react";
|
||||
import type { SidebarTab } from "@/components/GlobalSidebar";
|
||||
@@ -16,6 +21,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
import { McpTab } from "./McpTab";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { SchedulerTab } from "./SchedulerTab";
|
||||
import { SessionsTab } from "./SessionsTab";
|
||||
|
||||
export const SessionSidebar: FC<{
|
||||
@@ -65,6 +71,14 @@ export const SessionSidebar: FC<{
|
||||
title: "Show MCP server settings",
|
||||
content: <McpTab projectId={projectId} />,
|
||||
},
|
||||
{
|
||||
id: "scheduler",
|
||||
icon: CalendarClockIcon,
|
||||
title: "Show scheduler jobs",
|
||||
content: (
|
||||
<SchedulerTab projectId={projectId} sessionId={currentSessionId} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
sessions,
|
||||
|
||||
Reference in New Issue
Block a user