implement frontend

This commit is contained in:
d-kimsuon
2025-10-25 14:40:44 +09:00
parent 974d87daf7
commit ef4521750f
17 changed files with 2156 additions and 185 deletions

View File

@@ -8,3 +8,5 @@ export {
useContinueSessionProcessMutation,
useCreateSessionProcessMutation,
} from "./useChatMutations";
export type { UseMessageCompletionResult } from "./useMessageCompletion";
export { useMessageCompletion } from "./useMessageCompletion";

View File

@@ -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 = "&nbsp;";
const mirrored = document.createElement("div");
mirrored.innerHTML = "";
mirrored.append(pre, caret, post);
const textareaStyles = window.getComputedStyle(textarea);
for (const property of [
"border",
"boxSizing",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"padding",
"textDecoration",
"textIndent",
"textTransform",
"whiteSpace",
"wordSpacing",
"wordWrap",
] as const) {
mirrored.style[property] = textareaStyles[property];
}
mirrored.style.visibility = "hidden";
container.prepend(mirrored);
const caretRect = caret.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
container.removeChild(mirrored);
return {
relative: {
top: caretRect.top - containerRect.top - textarea.scrollTop,
left: caretRect.left - containerRect.left - textarea.scrollLeft,
},
absolute: {
top: caretRect.top - textarea.scrollTop,
left: caretRect.left - textarea.scrollLeft,
},
};
}, []);
const 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,
};
}

View File

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

View File

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