mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
fix(ui): workspace dialog scope filter + remove dead tab files
- Fix scope dropdown: cache scopes from initial "all" load so dropdown stays stable when filtering by chatID - Derive scopes from file chat_id when task scopes are empty - Remove dead files: team-workspace-tab.tsx, team-tasks-tab.tsx (replaced by board view and workspace dialog)
This commit is contained in:
1 parent
eee79d111e
commit
446feda3cd
3 files changed
+37
-520
No files matched your search
@@ -37,16 +37,45 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
|
||||
const scopeValue = selectedScope === "__all__" ? "" : selectedScope;
|
||||
// Stable scope list: populated once from "all" listing, not recalculated on filter.
|
||||
const [cachedScopes, setCachedScopes] = useState<ScopeEntry[]>([]);
|
||||
|
||||
// Load files for the selected scope. On "all" load, also cache scope list.
|
||||
const load = useCallback(() => {
|
||||
listFiles(teamId, scopeValue || undefined);
|
||||
listFiles(teamId, scopeValue || undefined).then((result) => {
|
||||
// Only update cached scopes from the "all" listing (no filter applied).
|
||||
if (!scopeValue && result.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const derived: ScopeEntry[] = [];
|
||||
for (const f of result) {
|
||||
if (f.chat_id && !seen.has(f.chat_id)) {
|
||||
seen.add(f.chat_id);
|
||||
derived.push({ channel: "", chat_id: f.chat_id });
|
||||
}
|
||||
}
|
||||
derived.sort((a, b) => a.chat_id.localeCompare(b.chat_id));
|
||||
setCachedScopes(derived);
|
||||
}
|
||||
});
|
||||
setFileContent(null);
|
||||
setActivePath(null);
|
||||
}, [teamId, listFiles, scopeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
if (open) {
|
||||
setSelectedScope("__all__"); // reset filter on open
|
||||
load();
|
||||
}
|
||||
}, [open]); // intentionally only depend on `open`, not `load`
|
||||
|
||||
// Re-fetch when scope changes (but not on initial open — handled above).
|
||||
useEffect(() => {
|
||||
if (open && scopeValue) {
|
||||
listFiles(teamId, scopeValue);
|
||||
setFileContent(null);
|
||||
setActivePath(null);
|
||||
}
|
||||
}, [open, scopeValue, teamId, listFiles]);
|
||||
|
||||
// Map relative name → absolute disk path (for HTTP file serving).
|
||||
const nameToAbsPath = useMemo(() => {
|
||||
@@ -55,6 +84,9 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
return m;
|
||||
}, [files]);
|
||||
|
||||
// Use provided scopes, or cached scopes from first "all" load.
|
||||
const effectiveScopes = scopes.length > 0 ? scopes : cachedScopes;
|
||||
|
||||
const tree = useMemo(
|
||||
() => buildTree(files.map((f) => ({
|
||||
path: f.name,
|
||||
@@ -122,14 +154,14 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>{t("workspace.title")}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{scopes.length > 0 && (
|
||||
{effectiveScopes.length > 1 && (
|
||||
<Select value={selectedScope} onValueChange={setSelectedScope}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs">
|
||||
<SelectValue placeholder={t("scope.all")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("scope.all")}</SelectItem>
|
||||
{scopes.map((s) => (
|
||||
{effectiveScopes.map((s) => (
|
||||
<SelectItem key={s.chat_id} value={s.chat_id}>
|
||||
{s.chat_id.length > 16 ? s.chat_id.slice(0, 16) + "\u2026" : s.chat_id}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Events } from "@/api/protocol";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment, ScopeEntry } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { TaskList } from "./task-sections";
|
||||
|
||||
type StatusFilter = "" | "in_review" | "completed" | "all";
|
||||
|
||||
interface TeamTasksTabProps {
|
||||
teamId: string;
|
||||
members: TeamMemberData[];
|
||||
scopes?: ScopeEntry[];
|
||||
isTeamV2?: boolean;
|
||||
getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
|
||||
getTaskDetail: (teamId: string, taskId: string) => Promise<{
|
||||
task: TeamTaskData;
|
||||
comments: TeamTaskComment[];
|
||||
events: TeamTaskEvent[];
|
||||
attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
|
||||
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
|
||||
createTask: (teamId: string, params: { subject: string; description?: string; priority?: number; taskType?: string; assignTo?: string; channel?: string; chatId?: string }) => Promise<TeamTaskData>;
|
||||
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const v1Filters: { value: StatusFilter; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "tasks.filters.active" },
|
||||
{ value: "completed", labelKey: "tasks.filters.completed" },
|
||||
{ value: "all", labelKey: "tasks.filters.all" },
|
||||
];
|
||||
|
||||
const v2Filters: { value: StatusFilter; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "tasks.filters.active" },
|
||||
{ value: "in_review", labelKey: "tasks.filters.inReview" },
|
||||
{ value: "completed", labelKey: "tasks.filters.completed" },
|
||||
{ value: "all", labelKey: "tasks.filters.all" },
|
||||
];
|
||||
|
||||
export function TeamTasksTab({
|
||||
teamId, members, scopes, isTeamV2,
|
||||
getTeamTasks,
|
||||
getTaskDetail,
|
||||
createTask,
|
||||
}: TeamTasksTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [tasks, setTasks] = useState<TeamTaskData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("");
|
||||
const [selectedScope, setSelectedScope] = useState<ScopeEntry | null>(null);
|
||||
const spinning = useMinLoading(loading);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState(0);
|
||||
const [taskType, setTaskType] = useState("general");
|
||||
const [assignTo, setAssignTo] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTeamTasks(teamId, statusFilter || undefined, selectedScope?.channel, selectedScope?.chat_id);
|
||||
setTasks(res.tasks ?? []);
|
||||
} catch (err) {
|
||||
console.error("[TeamTasksTab] load failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamId, getTeamTasks, statusFilter, selectedScope]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Auto-reload task list on real-time team task events.
|
||||
const reloadOnEvent = useCallback((payload: unknown) => {
|
||||
const p = payload as { team_id?: string };
|
||||
if (!p?.team_id || p.team_id === teamId) load();
|
||||
}, [teamId, load]);
|
||||
useWsEvent(Events.TEAM_TASK_CREATED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_CLAIMED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_COMPLETED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_CANCELLED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_REVIEWED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_APPROVED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_REJECTED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_PROGRESS, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_ASSIGNED, reloadOnEvent);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
setPriority(0);
|
||||
setTaskType("general");
|
||||
setAssignTo("");
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!subject.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createTask(teamId, {
|
||||
subject: subject.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
taskType: taskType || undefined,
|
||||
assignTo: assignTo || undefined,
|
||||
channel: selectedScope?.channel || undefined,
|
||||
chatId: selectedScope?.chat_id || undefined,
|
||||
});
|
||||
toast.success(t("toast.taskCreated"));
|
||||
setCreateOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t("toast.failedCreateTask"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status filter toggle */}
|
||||
<div className="flex rounded-lg border bg-muted/50 p-0.5">
|
||||
{(isTeamV2 ? v2Filters : v1Filters).map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
className={
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors " +
|
||||
(statusFilter === f.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")
|
||||
}
|
||||
>
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Scope filter */}
|
||||
{scopes && scopes.length > 0 && (
|
||||
<select
|
||||
value={selectedScope ? `${selectedScope.channel}:${selectedScope.chat_id}` : ""}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSelectedScope(null);
|
||||
} else {
|
||||
const idx = e.target.value.indexOf(":");
|
||||
setSelectedScope({ channel: e.target.value.slice(0, idx), chat_id: e.target.value.slice(idx + 1) });
|
||||
}
|
||||
}}
|
||||
className="rounded-md border bg-background px-2 py-1 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("scope.all")}</option>
|
||||
{scopes.map((s) => (
|
||||
<option key={`${s.channel}:${s.chat_id}`} value={`${s.channel}:${s.chat_id}`}>
|
||||
{s.channel}:{s.chat_id.length > 12 ? s.chat_id.slice(0, 12) + "…" : s.chat_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleOpenCreate} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("tasks.createTask")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("tasks.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
loading={loading}
|
||||
teamId={teamId}
|
||||
members={members}
|
||||
isTeamV2={isTeamV2}
|
||||
getTaskDetail={getTaskDetail}
|
||||
/>
|
||||
|
||||
{/* Create Task Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("tasks.createTaskTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Subject */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.columns.subject")} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t("tasks.subjectPlaceholder")}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.detail.description")}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("tasks.descriptionPlaceholder")}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Type + Priority row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.detail.type").replace(":", "")}
|
||||
</label>
|
||||
<select
|
||||
value={taskType}
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
>
|
||||
<option value="general">General</option>
|
||||
<option value="delegation">Delegation</option>
|
||||
<option value="escalation">Escalation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.priorityLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assign To */}
|
||||
{members.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.assignTo")}
|
||||
</label>
|
||||
<select
|
||||
value={assignTo}
|
||||
onChange={(e) => setAssignTo(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("tasks.unassigned")}</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.agent_id} value={m.agent_id}>
|
||||
{m.display_name || m.agent_key || m.agent_id}
|
||||
{m.role === "lead" ? " (Lead)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateOpen(false)} disabled={creating}>
|
||||
{t("create.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating || !subject.trim()}>
|
||||
{creating ? t("create.creating") : t("tasks.createTask")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RefreshCw, Trash2, FileText } from "lucide-react";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useTeamWorkspace } from "./hooks/use-team-workspace";
|
||||
import type { TeamWorkspaceFile, ScopeEntry } from "@/types/team";
|
||||
|
||||
interface TeamWorkspaceTabProps {
|
||||
teamId: string;
|
||||
scopes?: ScopeEntry[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function TeamWorkspaceTab({ teamId, scopes }: TeamWorkspaceTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { files, loading, listFiles, readFile, deleteFile } =
|
||||
useTeamWorkspace();
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
file: TeamWorkspaceFile;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [selectedScope, setSelectedScope] = useState<ScopeEntry | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const spinning = useMinLoading(loading);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listFiles(teamId, selectedScope?.chat_id).then(() => {
|
||||
setInitialized(true);
|
||||
});
|
||||
}, [teamId, listFiles, selectedScope]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
async (file: TeamWorkspaceFile) => {
|
||||
try {
|
||||
const res = await readFile(teamId, file.name, file.chat_id);
|
||||
setSelectedFile({ file: res.file ?? file, content: res.content ?? "" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[teamId, readFile],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (fileName: string) => {
|
||||
const file = files.find((f) => f.name === fileName);
|
||||
try {
|
||||
await deleteFile(teamId, fileName, file?.chat_id);
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[teamId, files, deleteFile, load],
|
||||
);
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">{t("workspace.title")}</h3>
|
||||
{scopes && scopes.length > 0 && (
|
||||
<select
|
||||
value={selectedScope ? selectedScope.chat_id : ""}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSelectedScope(null);
|
||||
} else {
|
||||
setSelectedScope({ channel: "", chat_id: e.target.value });
|
||||
}
|
||||
}}
|
||||
className="rounded-md border bg-background px-2 py-1 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("scope.all")}</option>
|
||||
{scopes.map((s) => (
|
||||
<option key={s.chat_id} value={s.chat_id}>
|
||||
{s.chat_id.length > 16 ? s.chat_id.slice(0, 16) + "…" : s.chat_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={load} disabled={spinning}>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
{t("workspace.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("workspace.noFiles")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
{t("workspace.noFilesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.fileName")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.size")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.scope")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.updated")}</th>
|
||||
<th className="pb-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr
|
||||
key={`${file.chat_id}/${file.name}`}
|
||||
className="cursor-pointer border-b border-border/50 transition-colors hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(file)}
|
||||
>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className="font-mono text-xs">{file.name}</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{formatBytes(file.size)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{file.chat_id?.slice(0, 12) || "_default"}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{formatDate(file.updated_at)}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(file.name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content dialog */}
|
||||
<Dialog open={!!selectedFile} onOpenChange={() => setSelectedFile(null)}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-sm">
|
||||
{selectedFile?.file.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<pre className="flex-1 min-h-0 overflow-auto rounded-md bg-muted p-4 text-xs -mx-4 sm:-mx-6">
|
||||
{selectedFile?.content || t("workspace.detail.empty")}
|
||||
</pre>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t("workspace.delete")}
|
||||
description={t("workspace.confirmDelete")}
|
||||
confirmValue={deleteTarget ?? ""}
|
||||
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user