Compare commits

...

1 Commits

Author SHA1 Message Date
Bartek Gatz 182d7aef0a fix: consistent task naming across all views
Implements a consistent task naming convention across the task list,
sidebar, and task details page by:

- Adding getCleanTaskName() utility that extracts human-readable names
  from workspace names (e.g., "task-fix-bug-abc123" -> "Fix Bug")
- Displaying clean task names instead of raw workspace names or prompts
- Adding tooltips on all task names showing full workspace name and prompt
- Maintaining full clickability and original styling

Fixes #20455

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 18:44:20 +00:00
5 changed files with 238 additions and 71 deletions
@@ -21,7 +21,7 @@ import {
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import { getCleanTaskName, type Task } from "modules/tasks/tasks";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
@@ -179,66 +179,89 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
const isActive = task.workspace.name === workspace;
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanTaskName = getCleanTaskName(task.workspace.name);
const truncatedPrompt =
task.prompt.length > 100 ? `${task.prompt.slice(0, 100)}...` : task.prompt;
return (
<>
<Button
asChild
size="sm"
variant="subtle"
className={cn(
"overflow-visible group w-full justify-start text-content-secondary",
"transition-none hover:bg-surface-tertiary gap-2 has-[[data-state=open]]:bg-surface-tertiary",
{
"text-content-primary bg-surface-quaternary hover:bg-surface-quaternary has-[[data-state=open]]:bg-surface-quaternary":
isActive,
},
)}
>
<RouterLink
to={{
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
search: window.location.search,
}}
>
<TaskSidebarMenuItemStatus task={task} />
<span className="truncate">{task.workspace.name}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className={`
ml-auto -mr-2 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100
focus-visible:opacity-100 data-[state=open]:opacity-100
`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
size="sm"
variant="subtle"
className={cn(
"overflow-visible group w-full justify-start text-content-secondary",
"transition-none hover:bg-surface-tertiary gap-2 has-[[data-state=open]]:bg-surface-tertiary",
{
"text-content-primary bg-surface-quaternary hover:bg-surface-quaternary has-[[data-state=open]]:bg-surface-quaternary":
isActive,
},
)}
>
<RouterLink
to={{
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
search: window.location.search,
}}
>
<EllipsisIcon />
<span className="sr-only">Task options</span>
</Button>
</DropdownMenuTrigger>
<TaskSidebarMenuItemStatus task={task} />
<span className="truncate">{cleanTaskName}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className={`
ml-auto -mr-2 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100
focus-visible:opacity-100 data-[state=open]:opacity-100
`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<EllipsisIcon />
<span className="sr-only">Task options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</RouterLink>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-md">
<div className="space-y-2">
<div>
<div className="text-xs text-content-secondary">
Workspace name
</div>
<div className="text-sm font-medium">{task.workspace.name}</div>
</div>
<div>
<div className="text-xs text-content-secondary">Prompt</div>
<div className="text-sm">{truncatedPrompt}</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TaskDeleteDialog
open={isDeleteDialogOpen}
+49
View File
@@ -0,0 +1,49 @@
import { getCleanTaskName } from "./tasks";
describe("getCleanTaskName", () => {
it("should remove task- prefix and identifier suffix", () => {
expect(getCleanTaskName("task-fix-login-bug-abc123")).toBe("Fix Login Bug");
expect(getCleanTaskName("task-add-feature-xyz789")).toBe("Add Feature");
expect(getCleanTaskName("task-update-docs-12345678")).toBe("Update Docs");
});
it("should handle workspace names without task- prefix", () => {
expect(getCleanTaskName("fix-login-bug-abc123")).toBe("Fix Login Bug");
expect(getCleanTaskName("simple-name-123")).toBe("Simple Name");
});
it("should handle short identifiers with numbers", () => {
expect(getCleanTaskName("task-test-feature-a1b2c3")).toBe("Test Feature");
expect(getCleanTaskName("task-debug-issue-99")).toBe("Debug Issue");
});
it("should handle UUID-like identifiers", () => {
expect(getCleanTaskName("task-implement-auth-a1b2c3d4")).toBe(
"Implement Auth",
);
});
it("should handle names without identifiers", () => {
expect(getCleanTaskName("task-simple")).toBe("Simple");
expect(getCleanTaskName("simple")).toBe("Simple");
});
it("should title case the result", () => {
expect(getCleanTaskName("task-fix-login-bug-123")).toBe("Fix Login Bug");
expect(getCleanTaskName("task-ADD-NEW-FEATURE-xyz")).toBe(
"Add New Feature",
);
});
it("should handle single word names", () => {
expect(getCleanTaskName("task-refactor")).toBe("Refactor");
expect(getCleanTaskName("deploy")).toBe("Deploy");
});
it("should remove last part if it could be an identifier", () => {
// The function removes the last part if it could potentially be an identifier
// This is conservative behavior to ensure clean names
expect(getCleanTaskName("task-update-user-profile")).toBe("Update User");
expect(getCleanTaskName("task-create-new-issue")).toBe("Create New");
});
});
+43
View File
@@ -25,3 +25,46 @@ export function getTaskApps(task: Task): WorkspaceAppWithAgent[] {
})),
);
}
/**
* Extracts a clean, human-readable task name from a workspace name.
* Workspace names typically follow the pattern: task-{name}-{identifier}
* This function removes the "task-" prefix and the identifier suffix,
* leaving just the middle portion with dashes converted to spaces and title cased.
*
* Examples:
* - "task-fix-login-bug-abc123" -> "Fix Login Bug"
* - "task-add-feature-xyz789" -> "Add Feature"
* - "simple-name" -> "Simple Name" (fallback for non-standard names)
*
* @param workspaceName - The full workspace name
* @returns A cleaned, human-readable task name
*/
export function getCleanTaskName(workspaceName: string): string {
// Remove "task-" prefix if present
const cleaned = workspaceName.startsWith("task-")
? workspaceName.slice(5)
: workspaceName;
// Split by dashes
const parts = cleaned.split("-");
// If we have multiple parts, remove the last one (likely an identifier)
// Only do this if the last part looks like an identifier (short alphanumeric)
if (parts.length > 2) {
const lastPart = parts[parts.length - 1];
// Check if last part is likely an identifier (short, contains numbers, or is a UUID-like string)
if (
lastPart.length <= 8 ||
/\d/.test(lastPart) ||
/^[a-f0-9]{8,}$/i.test(lastPart)
) {
parts.pop();
}
}
// Join remaining parts with spaces and title case each word
return parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
}
+28 -4
View File
@@ -13,7 +13,7 @@ import {
LaptopMinimalIcon,
TerminalIcon,
} from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import { getCleanTaskName, type Task } from "modules/tasks/tasks";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { TaskStatusLink } from "./TaskStatusLink";
@@ -21,6 +21,10 @@ import { TaskStatusLink } from "./TaskStatusLink";
type TaskTopbarProps = { task: Task };
export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
const cleanTaskName = getCleanTaskName(task.workspace.name);
const truncatedPrompt =
task.prompt.length > 100 ? `${task.prompt.slice(0, 100)}...` : task.prompt;
return (
<header className="flex flex-shrink-0 items-center p-3 border-solid border-border border-0 border-b">
<TooltipProvider>
@@ -37,9 +41,29 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
</Tooltip>
</TooltipProvider>
<h1 className="m-0 pl-2 text-base font-medium truncate">
{task.workspace.name}
</h1>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h1 className="m-0 pl-2 text-base font-medium truncate cursor-help">
{cleanTaskName}
</h1>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<div className="space-y-2">
<div>
<div className="text-xs text-content-secondary">
Workspace name
</div>
<div className="text-sm font-medium">{task.workspace.name}</div>
</div>
<div>
<div className="text-xs text-content-secondary">Prompt</div>
<div className="text-sm">{truncatedPrompt}</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 flex-wrap ml-4">
+40 -12
View File
@@ -24,7 +24,7 @@ import {
} from "components/Tooltip/Tooltip";
import { RotateCcwIcon, TrashIcon } from "lucide-react";
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
import type { Task } from "modules/tasks/tasks";
import { getCleanTaskName, type Task } from "modules/tasks/tasks";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import { WorkspaceStatus } from "modules/workspaces/WorkspaceStatus/WorkspaceStatus";
import { type FC, type ReactNode, useState } from "react";
@@ -119,24 +119,52 @@ const TaskRow: FC<TaskRowProps> = ({ task }) => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const templateDisplayName =
task.workspace.template_display_name ?? task.workspace.template_name;
const cleanTaskName = getCleanTaskName(task.workspace.name);
const truncatedPrompt =
task.prompt.length > 100 ? `${task.prompt.slice(0, 100)}...` : task.prompt;
return (
<>
<TableRow key={task.workspace.id} className="relative" hover>
<TableCell>
<RouterLink
to={`/tasks/${task.workspace.owner_name}/${task.workspace.name}`}
className="absolute inset-0"
>
<span className="sr-only">Access task</span>
</RouterLink>
<AvatarData
title={
<>
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
{task.prompt}
</span>
<RouterLink
to={`/tasks/${task.workspace.owner_name}/${task.workspace.name}`}
className="absolute inset-0"
>
<span className="sr-only">Access task</span>
</RouterLink>
</>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<RouterLink
to={`/tasks/${task.workspace.owner_name}/${task.workspace.name}`}
className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap cursor-help relative z-10 no-underline text-inherit"
>
{cleanTaskName}
</RouterLink>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<div className="space-y-2">
<div>
<div className="text-xs text-content-secondary">
Workspace name
</div>
<div className="text-sm font-medium">
{task.workspace.name}
</div>
</div>
<div>
<div className="text-xs text-content-secondary">
Prompt
</div>
<div className="text-sm">{truncatedPrompt}</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
subtitle={templateDisplayName}
avatar={