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:
viettranx committed 2026-03-16 22:47:04 +07:00
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>
-302
View File
@@ -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>
);
}