From 3c49292e34f23f750c1ba379d0ccaee6c764161d Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 15:53:44 +0200 Subject: [PATCH 1/5] chore: add biome formatter scaffolding --- .github/workflows/ci.yml | 2 ++ biome.jsonc | 50 ++++++++++++++++++++++++++++++++++++++++ bun.lock | 19 +++++++++++++++ package.json | 3 +++ 4 files changed, 74 insertions(+) create mode 100644 biome.jsonc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5bda8b..e1b29f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,8 @@ jobs: echo " Examples: 'feat: add task graph', 'fix(ai): rate limiter timing'" exit 1 fi + - name: Format check + run: bun run format:check - name: Lint run: bun run lint - name: Typecheck diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..2b2b662 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,50 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "root": true, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!**/node_modules/**", + "!**/.next/**", + "!**/out/**", + "!**/build/**", + "!**/dist/**", + "!**/coverage/**", + "!**/.vercel/**", + "!**/*.tsbuildinfo", + "!**/next-env.d.ts", + "!bun.lock", + "!migrations/**", + "!drizzle/**" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80, + "lineEnding": "lf" + }, + "linter": { "enabled": false }, + "assist": { "enabled": false }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "jsxQuoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always" + } + }, + "json": { + "formatter": { + "trailingCommas": "none" + } + } +} diff --git a/bun.lock b/bun.lock index f9e390d..8c85d7f 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "zod": "4.4.3", }, "devDependencies": { + "@biomejs/biome": "2.4.15", "@types/d3-force": "^3.0.10", "@types/node": "^24.12.4", "@types/react": "^19.2.14", @@ -101,6 +102,24 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/package.json b/package.json index 8301079..72b699f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test": "bun test", "test:db": "bun test tests/db tests/data tests/auth", "lint": "eslint .", + "format": "biome format --write .", + "format:check": "biome format .", "typecheck": "tsc --noEmit", "check:plugins": "bun run scripts/check-plugins.ts", "sync:plugins": "bun run scripts/check-plugins.ts --fix", @@ -52,6 +54,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@biomejs/biome": "2.4.15", "@types/d3-force": "^3.0.10", "@types/node": "^24.12.4", "@types/react": "^19.2.14", From 7bfa8a51bf433eeb8f0bc63c0e523c9b1ce54348 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 15:55:15 +0200 Subject: [PATCH 2/5] chore: apply biome format baseline --- app/(auth)/consent/page.tsx | 11 +- app/(auth)/sign-in/page.tsx | 22 +- app/(auth)/sign-up/page.tsx | 22 +- app/_components/HomeGrid.tsx | 8 +- app/api/project/[projectId]/graph/route.ts | 20 +- app/api/task/[taskId]/route.ts | 22 +- app/dev/primitives/PrimitivesShowcase.tsx | 639 +++++++--- app/dev/primitives/page.tsx | 8 +- app/globals.css | 180 ++- app/layout.tsx | 11 +- .../_components/WorkspaceClient.tsx | 124 +- app/project/[projectId]/layout.tsx | 26 +- app/settings/_components/AccountTab.tsx | 24 +- app/settings/_components/AgentSection.tsx | 16 +- app/settings/_components/AgentSessionRow.tsx | 19 +- app/settings/_components/AgentsTab.tsx | 16 +- app/settings/_components/CreateTeamPanel.tsx | 26 +- app/settings/_components/EmptyState.tsx | 2 +- app/settings/_components/InlineConfirm.tsx | 16 +- app/settings/_components/JoinTeamPanel.tsx | 21 +- app/settings/_components/SettingsView.tsx | 171 +-- app/settings/_components/TeamCard.tsx | 132 +- app/settings/_components/TeamManageModal.tsx | 140 ++- app/settings/_components/TeamsTab.tsx | 42 +- app/settings/_components/team-manage-cache.ts | 6 +- .../_components/team-manage/DangerZone.tsx | 19 +- .../team-manage/DeleteTeamDialog.tsx | 55 +- .../team-manage/IdentitySection.tsx | 42 +- .../team-manage/InviteCodePanel.tsx | 56 +- .../_components/team-manage/InviteForm.tsx | 82 +- .../_components/team-manage/InviteSection.tsx | 12 +- .../_components/team-manage/MemberRow.tsx | 92 +- .../team-manage/MembersSection.tsx | 15 +- .../team-manage/PendingInvitationsList.tsx | 22 +- app/settings/loading.tsx | 4 +- app/settings/page.tsx | 18 +- biome.jsonc | 5 + components/auth/AuthBrand.tsx | 6 +- components/auth/AuthHero.tsx | 22 +- components/auth/AuthInput.tsx | 23 +- components/auth/AuthShell.tsx | 4 +- components/auth/AuthSubmit.tsx | 18 +- components/auth/SignInForm.tsx | 25 +- components/auth/SignUpForm.tsx | 27 +- components/auth/SocialButtons.tsx | 12 +- components/graph/ForceGraph.tsx | 289 +++-- components/graph/GraphControls.tsx | 16 +- components/graph/graphConstants.ts | 55 +- components/graph/useForceSimulation.ts | 13 +- components/home/ContinueBanner.tsx | 17 +- components/home/GetStartedModal.tsx | 60 +- components/home/NewProjectButton.tsx | 10 +- components/home/ProjectCard.tsx | 82 +- components/home/ProjectStatusModal.tsx | 49 +- components/home/TeamFilterBar.tsx | 111 +- components/layout/AppShell.tsx | 46 +- components/layout/MotionProvider.tsx | 4 +- components/layout/PageShell.tsx | 10 +- components/layout/ProjectBreadcrumb.tsx | 54 +- components/layout/Sidebar.tsx | 145 ++- components/layout/SidebarCollapseProvider.tsx | 10 +- components/layout/ThemeProvider.tsx | 27 +- components/layout/TopBar.tsx | 65 +- components/layout/TwoPanelLayout.tsx | 41 +- components/layout/WorkspaceLabelProvider.tsx | 13 +- components/providers/QueryProvider.tsx | 5 +- components/providers/SessionProvider.tsx | 3 +- components/shared/AutoGrowTextarea.tsx | 13 +- components/shared/Avatar.tsx | 44 +- components/shared/Badge.tsx | 56 +- components/shared/Button.tsx | 81 +- components/shared/Card.tsx | 32 +- components/shared/CategoryDot.tsx | 12 +- components/shared/Checkbox.tsx | 32 +- components/shared/CopyButton.tsx | 26 +- components/shared/DeferredLoadingSpinner.tsx | 14 +- components/shared/Dropdown.tsx | 145 +-- components/shared/IconButton.tsx | 41 +- components/shared/Kbd.tsx | 14 +- components/shared/LoadingSpinner.tsx | 12 +- components/shared/Markdown.tsx | 20 +- components/shared/Modal.tsx | 41 +- components/shared/MonoId.tsx | 99 +- components/shared/PriorityIcon.tsx | 38 +- components/shared/ProgressBar.tsx | 27 +- components/shared/StatusGlyph.tsx | 201 +++- components/shared/TabSwitcher.tsx | 53 +- components/shared/TeamChip.tsx | 24 +- components/shared/ViewTabs.tsx | 46 +- components/shared/host-icons.tsx | 8 +- components/shared/icons.tsx | 80 +- components/workspace/BundlePreview.tsx | 482 +++++--- components/workspace/DetailPanel.tsx | 10 +- components/workspace/NavigatorPanel.tsx | 109 +- components/workspace/WorkspaceHeader.tsx | 20 +- .../workspace/detail/ActivitySection.tsx | 18 +- .../workspace/detail/CriteriaSection.tsx | 234 ++-- .../workspace/detail/DecisionsSection.tsx | 239 ++-- .../workspace/detail/DescriptionSection.tsx | 33 +- components/workspace/detail/DetailHeader.tsx | 81 +- components/workspace/detail/DetailView.tsx | 128 +- .../workspace/detail/ExecutionSection.tsx | 10 +- components/workspace/detail/LinksSection.tsx | 183 ++- components/workspace/detail/PlanSection.tsx | 206 ++-- components/workspace/detail/PropRail.tsx | 1059 ++++++++++------- .../workspace/detail/PropRailDrawer.tsx | 29 +- .../workspace/detail/RelationshipsSection.tsx | 309 +++-- components/workspace/detail/SectionHeader.tsx | 13 +- .../workspace/graph/EdgeFilterPills.tsx | 28 +- components/workspace/graph/GraphHoverCard.tsx | 18 +- .../graph/GraphRailCollapseProvider.tsx | 14 +- components/workspace/graph/MiniTaskRail.tsx | 53 +- components/workspace/graph/StatusLegend.tsx | 38 +- .../workspace/graph/WorkspaceGraphView.tsx | 66 +- .../project-settings/CategoriesSection.tsx | 74 +- .../project-settings/DescriptionSection.tsx | 46 +- .../project-settings/IdentifierSection.tsx | 182 +-- .../project-settings/ProjectSettingsModal.tsx | 26 +- .../project-settings/StatusSection.tsx | 86 +- .../project-settings/TeamSection.tsx | 6 +- .../project-settings/TitleSection.tsx | 41 +- .../workspace/structure/DeleteConfirm.tsx | 14 +- components/workspace/structure/FilterBar.tsx | 79 +- .../workspace/structure/FilterPanel.tsx | 89 +- .../workspace/structure/StructureView.tsx | 533 +++++---- components/workspace/structure/TaskGroup.tsx | 6 +- components/workspace/structure/TaskRow.tsx | 94 +- .../workspace/structure/relativeTime.ts | 13 +- eslint.config.mjs | 3 +- hooks/useCopyToClipboard.ts | 41 +- hooks/useMediaQuery.ts | 15 +- hooks/useModalChrome.ts | 12 +- hooks/usePopoverAnchor.ts | 84 +- hooks/useUndo.tsx | 37 +- lib/actions/oauth-session.ts | 40 +- lib/actions/profile.ts | 35 +- lib/actions/project.ts | 203 ++-- lib/actions/rate-limit-action.ts | 6 +- lib/actions/team-errors.ts | 3 +- lib/actions/team-invitations-map.ts | 5 +- lib/actions/team-invitations.ts | 59 +- lib/actions/team-invite-code.ts | 35 +- lib/actions/team-list.ts | 38 +- lib/actions/team-members-map.ts | 3 +- lib/actions/team-members.ts | 26 +- lib/actions/team.ts | 10 +- lib/api/rate-limit.ts | 15 +- lib/auth.ts | 7 +- lib/auth/authorization.ts | 3 +- lib/auth/permissions.ts | 8 +- lib/context/_core/agent.ts | 15 +- lib/context/_core/planning.ts | 13 +- lib/context/_core/review.ts | 9 +- lib/context/_core/summary.ts | 6 +- lib/context/format.ts | 10 +- lib/data/access.ts | 7 +- lib/data/cursor.ts | 3 +- lib/data/edge.ts | 17 +- lib/data/membership.ts | 20 +- lib/data/oauth-session.ts | 5 +- lib/data/project.ts | 18 +- lib/data/task.ts | 182 +-- lib/data/team-invite-code.ts | 3 +- lib/db/auth-schema.ts | 60 +- lib/db/raw.ts | 6 +- lib/db/raw/fetch-task-children.ts | 4 +- lib/db/raw/fetch-task-full.ts | 4 +- lib/db/raw/get-project-list-max-updated-at.ts | 4 +- lib/db/schema.ts | 74 +- lib/graph/format-responses.ts | 90 +- lib/graph/identifier.ts | 22 +- lib/graph/mutations.ts | 5 +- lib/graph/tag-similarity.ts | 5 +- lib/graph/tool-handlers.ts | 122 +- lib/links/classify.ts | 22 +- lib/markdown/format.ts | 34 +- lib/mcp/create-server.ts | 372 ++++-- lib/query/queries.ts | 4 +- lib/team/derive-slug.ts | 12 +- lib/theme.ts | 5 +- lib/types.ts | 8 +- lib/ui/initials.ts | 11 +- lib/ui/oauth-client-name.ts | 12 +- lib/ui/relative-time.ts | 14 +- lib/ui/role-badge.ts | 21 +- lib/ui/status.ts | 60 +- lib/ui/team-avatar.ts | 5 +- .../claude-code/.claude-plugin/plugin.json | 7 +- plugins/codex/.codex-plugin/plugin.json | 6 +- proxy.ts | 4 +- scripts/check-plugins.ts | 77 +- tests/actions/map-better-auth-error.test.ts | 26 +- tests/actions/team-invite-code-action.test.ts | 42 +- tests/api/error.test.ts | 8 +- tests/api/events.test.ts | 223 ++-- tests/api/task-context.test.ts | 14 +- tests/auth/authorization.test.ts | 16 +- tests/auth/cookie-attributes.test.ts | 4 +- tests/auth/rate-limit.test.ts | 17 +- tests/context/agent.test.ts | 5 +- tests/context/overview.test.ts | 5 +- tests/context/planning.test.ts | 5 +- tests/context/review.test.ts | 5 +- tests/context/summary.test.ts | 5 +- tests/context/working.test.ts | 5 +- tests/data/account.test.ts | 9 +- tests/data/membership.test.ts | 8 +- tests/data/project.test.ts | 18 +- tests/data/rls-dataring.test.ts | 10 +- tests/data/rls-team-invite-code.test.ts | 26 +- tests/data/rls.test.ts | 28 +- tests/data/task-links.test.ts | 123 +- tests/data/task.test.ts | 56 +- tests/db/connection.test.ts | 9 +- tests/db/neon-auth-lockdown.test.ts | 22 +- tests/db/raw.test.ts | 4 +- tests/db/rls-coverage.test.ts | 14 +- tests/db/rls-functions.test.ts | 134 ++- tests/db/rls.test.ts | 6 +- tests/graph/tool-handlers-edge.test.ts | 8 +- tests/links/classify.test.ts | 4 +- tests/realtime/access.test.ts | 8 +- tests/realtime/broker.test.ts | 10 +- .../security/eslint-bare-transaction.test.ts | 80 +- tests/security/headers.test.ts | 6 +- tests/security/no-set-config-in-sdfs.test.ts | 6 +- tests/setup/global.ts | 5 +- tests/setup/migrate.ts | 8 +- tests/setup/preload.ts | 5 +- tests/ui/links-anchor-safety.test.ts | 2 +- tsconfig.json | 14 +- 231 files changed, 7859 insertions(+), 4446 deletions(-) diff --git a/app/(auth)/consent/page.tsx b/app/(auth)/consent/page.tsx index a7834a6..62b3fdf 100644 --- a/app/(auth)/consent/page.tsx +++ b/app/(auth)/consent/page.tsx @@ -146,10 +146,9 @@ export default function ConsentPage() { useEffect(() => { if (!clientId) return; const controller = new AbortController(); - fetch( - `/api/oauth/consent-meta?client_id=${encodeURIComponent(clientId)}`, - { signal: controller.signal }, - ) + fetch(`/api/oauth/consent-meta?client_id=${encodeURIComponent(clientId)}`, { + signal: controller.signal, + }) .then(async (res) => { if (!res.ok) { setFetchError( @@ -227,8 +226,8 @@ export default function ConsentPage() { Authorization sent

- Return to your application to finish signing in. You can close - this tab. + Return to your application to finish signing in. You can close this + tab.

diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 7b25500..c441d90 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,9 +1,9 @@ -import Link from 'next/link'; -import { AuthShell } from '@/components/auth/AuthShell'; -import { AuthBrand } from '@/components/auth/AuthBrand'; -import { AuthHero } from '@/components/auth/AuthHero'; -import { SocialButtons } from '@/components/auth/SocialButtons'; -import { SignInForm } from '@/components/auth/SignInForm'; +import Link from "next/link"; +import { AuthShell } from "@/components/auth/AuthShell"; +import { AuthBrand } from "@/components/auth/AuthBrand"; +import { AuthHero } from "@/components/auth/AuthHero"; +import { SocialButtons } from "@/components/auth/SocialButtons"; +import { SignInForm } from "@/components/auth/SignInForm"; /** * Sign-in page — two-column auth surface matching the design prototype. @@ -26,7 +26,7 @@ export default function SignInPage() {

Walk into every session knowing what to do next.

@@ -34,19 +34,19 @@ export default function SignInPage() { className="mb-7 mt-2.5 text-[13.5px] text-text-muted" style={{ lineHeight: 1.55 }} > - The agent-native project graph. Sign in to continue, or onboard - a repo from your CLI. + The agent-native project graph. Sign in to continue, or onboard a + repo from your CLI.

- New to Mymir?{' '} + New to Mymir?{" "} Create an account diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index 864c8fd..8730ece 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -1,9 +1,9 @@ -import Link from 'next/link'; -import { AuthShell } from '@/components/auth/AuthShell'; -import { AuthBrand } from '@/components/auth/AuthBrand'; -import { AuthHero } from '@/components/auth/AuthHero'; -import { SocialButtons } from '@/components/auth/SocialButtons'; -import { SignUpForm } from '@/components/auth/SignUpForm'; +import Link from "next/link"; +import { AuthShell } from "@/components/auth/AuthShell"; +import { AuthBrand } from "@/components/auth/AuthBrand"; +import { AuthHero } from "@/components/auth/AuthHero"; +import { SocialButtons } from "@/components/auth/SocialButtons"; +import { SignUpForm } from "@/components/auth/SignUpForm"; /** * Sign-up page — mirrors the sign-in two-column shell with the @@ -23,7 +23,7 @@ export default function SignUpPage() {

Create an account.

@@ -31,19 +31,19 @@ export default function SignUpPage() { className="mb-7 mt-2.5 text-[13.5px] text-text-muted" style={{ lineHeight: 1.55 }} > - Your project graph and decision history live here. Connect - agents through MCP from your CLI once you’re in. + Your project graph and decision history live here. Connect agents + through MCP from your CLI once you’re in.

- Already have an account?{' '} + Already have an account?{" "} Sign in diff --git a/app/_components/HomeGrid.tsx b/app/_components/HomeGrid.tsx index b89bb07..022d460 100644 --- a/app/_components/HomeGrid.tsx +++ b/app/_components/HomeGrid.tsx @@ -204,7 +204,9 @@ function GroupedGrid({ projects, teams }: GroupedGridProps) { totalTasks={project.taskStats.total} cancelledTasks={project.taskStats.cancelled} tasksInProgress={project.taskStats.inProgress} - lastActive={dateFormatter.format(new Date(project.updatedAt))} + lastActive={dateFormatter.format( + new Date(project.updatedAt), + )} canDelete={roleHasProjectPermission(project.memberRole, [ "delete", ])} @@ -241,7 +243,9 @@ interface EmptyFilterHintProps { /** Hint shown when the current filter has no matches. */ function EmptyFilterHint({ teamFilter, teams }: EmptyFilterHintProps) { - const filteredTeam = teamFilter ? teams.find((t) => t.id === teamFilter) : undefined; + const filteredTeam = teamFilter + ? teams.find((t) => t.id === teamFilter) + : undefined; return ( { try { ctx = await getAuthContext(); } catch { - return error('Unauthorized', 401); + return error("Unauthorized", 401); } try { const max = await getProjectMaxUpdatedAt(ctx, projectId); - if (req.method === 'HEAD' || etagMatches(req, max)) { + if (req.method === "HEAD" || etagMatches(req, max)) { return conditionalRespond(req, null, max); } @@ -45,9 +45,9 @@ async function handle(req: Request, projectId: string): Promise { return conditionalRespond(req, body, max); } catch (err) { if (err instanceof ForbiddenError) { - return error('Project not found', 404); + return error("Project not found", 404); } - return internalError('graph', err); + return internalError("graph", err); } } diff --git a/app/api/task/[taskId]/route.ts b/app/api/task/[taskId]/route.ts index 225f333..e0664bb 100644 --- a/app/api/task/[taskId]/route.ts +++ b/app/api/task/[taskId]/route.ts @@ -1,10 +1,10 @@ -import { getAuthContext } from '@/lib/auth/context'; -import { ForbiddenError, assertTaskAccess } from '@/lib/auth/authorization'; -import { conditionalRespond, etagMatches } from '@/lib/api/conditional'; -import { getTaskFull } from '@/lib/data/task'; -import { broker } from '@/lib/realtime/broker'; -import { internalError } from '@/lib/api/error'; -import { error } from '@/lib/api/response'; +import { getAuthContext } from "@/lib/auth/context"; +import { ForbiddenError, assertTaskAccess } from "@/lib/auth/authorization"; +import { conditionalRespond, etagMatches } from "@/lib/api/conditional"; +import { getTaskFull } from "@/lib/data/task"; +import { broker } from "@/lib/realtime/broker"; +import { internalError } from "@/lib/api/error"; +import { error } from "@/lib/api/response"; /** TTL for fetch-implicit task subscriptions — 10 minutes. */ const TASK_SUBSCRIPTION_TTL_MS = 10 * 60_000; @@ -35,7 +35,7 @@ async function handle(req: Request, taskId: string): Promise { try { ctx = await getAuthContext(); } catch { - return error('Unauthorized', 401); + return error("Unauthorized", 401); } try { @@ -47,7 +47,7 @@ async function handle(req: Request, taskId: string): Promise { // call authorizes itself. const access = await assertTaskAccess(taskId, ctx); - if (req.method === 'HEAD' || etagMatches(req, access.updatedAt)) { + if (req.method === "HEAD" || etagMatches(req, access.updatedAt)) { return conditionalRespond(req, null, access.updatedAt); } @@ -59,9 +59,9 @@ async function handle(req: Request, taskId: string): Promise { return conditionalRespond(req, task, task.updatedAt); } catch (err) { if (err instanceof ForbiddenError) { - return error('Task not found', 404); + return error("Task not found", 404); } - return internalError('task', err); + return internalError("task", err); } } diff --git a/app/dev/primitives/PrimitivesShowcase.tsx b/app/dev/primitives/PrimitivesShowcase.tsx index 5fabd2c..017dd67 100644 --- a/app/dev/primitives/PrimitivesShowcase.tsx +++ b/app/dev/primitives/PrimitivesShowcase.tsx @@ -1,24 +1,24 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { Avatar } from '@/components/shared/Avatar'; -import { Badge } from '@/components/shared/Badge'; -import { Button } from '@/components/shared/Button'; -import { Card } from '@/components/shared/Card'; -import { Checkbox } from '@/components/shared/Checkbox'; -import { CopyButton } from '@/components/shared/CopyButton'; -import { IconButton } from '@/components/shared/IconButton'; -import { Kbd } from '@/components/shared/Kbd'; -import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; -import { Markdown } from '@/components/shared/Markdown'; -import { Modal } from '@/components/shared/Modal'; -import { MonoId } from '@/components/shared/MonoId'; -import { PriorityIcon, type Priority } from '@/components/shared/PriorityIcon'; -import { ProgressBar } from '@/components/shared/ProgressBar'; -import { StatusGlyph, type TaskStatus } from '@/components/shared/StatusGlyph'; -import { TabSwitcher } from '@/components/shared/TabSwitcher'; -import { TeamChip } from '@/components/shared/TeamChip'; -import { ViewTabs } from '@/components/shared/ViewTabs'; +import { useState } from "react"; +import { Avatar } from "@/components/shared/Avatar"; +import { Badge } from "@/components/shared/Badge"; +import { Button } from "@/components/shared/Button"; +import { Card } from "@/components/shared/Card"; +import { Checkbox } from "@/components/shared/Checkbox"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { IconButton } from "@/components/shared/IconButton"; +import { Kbd } from "@/components/shared/Kbd"; +import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; +import { Markdown } from "@/components/shared/Markdown"; +import { Modal } from "@/components/shared/Modal"; +import { MonoId } from "@/components/shared/MonoId"; +import { PriorityIcon, type Priority } from "@/components/shared/PriorityIcon"; +import { ProgressBar } from "@/components/shared/ProgressBar"; +import { StatusGlyph, type TaskStatus } from "@/components/shared/StatusGlyph"; +import { TabSwitcher } from "@/components/shared/TabSwitcher"; +import { TeamChip } from "@/components/shared/TeamChip"; +import { ViewTabs } from "@/components/shared/ViewTabs"; import { IconAgent, IconArrowRight, @@ -52,10 +52,19 @@ import { IconUser, IconUsers, IconX, -} from '@/components/shared/icons'; +} from "@/components/shared/icons"; -const STATUSES: TaskStatus[] = ['draft', 'planned', 'ready', 'in_progress', 'in_review', 'blocked', 'done', 'cancelled']; -const PRIORITIES: Priority[] = [null, 'backlog', 'normal', 'core', 'urgent']; +const STATUSES: TaskStatus[] = [ + "draft", + "planned", + "ready", + "in_progress", + "in_review", + "blocked", + "done", + "cancelled", +]; +const PRIORITIES: Priority[] = [null, "backlog", "normal", "core", "urgent"]; interface SectionProps { title: string; @@ -72,10 +81,10 @@ function Section({ title, caption, children }: SectionProps) { return (

@@ -84,16 +93,24 @@ function Section({ title, caption, children }: SectionProps) { margin: 0, fontSize: 14, fontWeight: 600, - color: 'var(--color-text-primary)', - fontFamily: 'var(--font-mono)', - letterSpacing: '0.04em', - textTransform: 'uppercase', + color: "var(--color-text-primary)", + fontFamily: "var(--font-mono)", + letterSpacing: "0.04em", + textTransform: "uppercase", }} > {title} {caption ? ( -

{caption}

+

+ {caption} +

) : null}
{children} @@ -111,13 +128,13 @@ function Swatch({ name, value, isGradient = false }: SwatchProps) { return (
- {name} + + {name} +
); } const ICONS_LIST: Array<{ name: string; node: React.ReactNode }> = [ - { name: 'ChevronDown', node: }, - { name: 'ChevronRight', node: }, - { name: 'ChevronUp', node: }, - { name: 'ChevronLeft', node: }, - { name: 'Search', node: }, - { name: 'Inbox', node: }, - { name: 'Agent', node: }, - { name: 'User', node: }, - { name: 'Users', node: }, - { name: 'Plus', node: }, - { name: 'Filter', node: }, - { name: 'Sort', node: }, - { name: 'List', node: }, - { name: 'Graph', node: }, - { name: 'Link', node: }, - { name: 'Spark', node: }, - { name: 'Settings', node: }, - { name: 'Moon', node: }, - { name: 'Sun', node: }, - { name: 'Check', node: }, - { name: 'X', node: }, - { name: 'More', node: }, - { name: 'ArrowRight', node: }, - { name: 'Command', node: }, - { name: 'Doc', node: }, - { name: 'Branch', node: }, - { name: 'Bundle', node: }, - { name: 'Lock', node: }, - { name: 'Flag', node: }, - { name: 'Tag', node: }, - { name: 'Clock', node: }, - { name: 'Trash', node: }, + { name: "ChevronDown", node: }, + { name: "ChevronRight", node: }, + { name: "ChevronUp", node: }, + { name: "ChevronLeft", node: }, + { name: "Search", node: }, + { name: "Inbox", node: }, + { name: "Agent", node: }, + { name: "User", node: }, + { name: "Users", node: }, + { name: "Plus", node: }, + { name: "Filter", node: }, + { name: "Sort", node: }, + { name: "List", node: }, + { name: "Graph", node: }, + { name: "Link", node: }, + { name: "Spark", node: }, + { name: "Settings", node: }, + { name: "Moon", node: }, + { name: "Sun", node: }, + { name: "Check", node: }, + { name: "X", node: }, + { name: "More", node: }, + { name: "ArrowRight", node: }, + { name: "Command", node: }, + { name: "Doc", node: }, + { name: "Branch", node: }, + { name: "Bundle", node: }, + { name: "Lock", node: }, + { name: "Flag", node: }, + { name: "Tag", node: }, + { name: "Clock", node: }, + { name: "Trash", node: }, ]; /** @@ -177,8 +202,8 @@ const ICONS_LIST: Array<{ name: string; node: React.ReactNode }> = [ * @returns A composed page of sections — tokens, icons, buttons, glyphs, etc. */ export function PrimitivesShowcase() { - const [tab, setTab] = useState('structure'); - const [view, setView] = useState('list'); + const [tab, setTab] = useState("structure"); + const [view, setView] = useState("list"); const [check1, setCheck1] = useState(false); const [check2, setCheck2] = useState(true); const [modalOpen, setModalOpen] = useState(false); @@ -186,117 +211,279 @@ export function PrimitivesShowcase() { return (
-
+
-

+

Primitives

-

- Phase 0 design system — every shared primitive in every state. Toggle the theme via the existing app theme - to verify light/dark parity. +

+ Phase 0 design system — every shared primitive in every state. + Toggle the theme via the existing app theme to verify light/dark + parity.

-
-
+
+
- - + + - + - + - + - - + +
-
-
-

+

+
+

Page H1 — 26 / 600 / -0.01em

-

+

Detail title — 22 / 600

-

Section H2 — 16 / 600

-

- Body — 13.5 / 450 with leading 1.55. The agent-native project graph. Walk into every session knowing what - to do next. +

+ Section H2 — 16 / 600 +

+

+ Body — 13.5 / 450 with leading 1.55. The agent-native project + graph. Walk into every session knowing what to do next.

-

+

Label — 12 / 500 muted

Section label — 11 / 600 mono uppercase 0.08em

-

+

MYMR-104 — mono 11 / 500

-
-
- {(['sm', 'md', 'lg'] as const).map((size) => ( -
- +
+
+ {(["sm", "md", "lg"] as const).map((size) => ( +
+ {size} - - - - - - - - - + + + + + + + + +
))}
-
-
-
+
+
+
{STATUSES.map((s) => ( -
+
- {s} + + {s} +
))}
-
+
{STATUSES.map((s) => ( ))}
-
+
@@ -305,19 +492,46 @@ export function PrimitivesShowcase() {
-
-
+
+
{PRIORITIES.map((p) => ( -
+
- {p ?? 'null'} + + {p ?? "null"} +
))}
-
-
+
+
{[18, 22, 28, 56].map((s) => ( ))} @@ -330,12 +544,25 @@ export function PrimitivesShowcase() {
-
+
⌘K N ESC ? - + @@ -343,41 +570,74 @@ export function PrimitivesShowcase() {
-
+
}, - { id: 'graph', label: 'Graph', icon: }, - { id: 'agent', label: 'Agent feed', icon: , pulse: true }, + { id: "list", label: "Structure", icon: }, + { id: "graph", label: "Graph", icon: }, + { + id: "agent", + label: "Agent feed", + icon: , + pulse: true, + }, ]} activeId={view} onChange={setView} />
-
+
-
+
-

Plain card

-

+

+ Plain card +

+

Surface + 1px border + card shadow.

undefined}> -

Hoverable card

-

+

+ Hoverable card +

+

Glow-on-hover with stronger border and lift shadow.

@@ -385,14 +645,29 @@ export function PrimitivesShowcase() {
-
- - +
+ +
-
+
@@ -405,7 +680,7 @@ export function PrimitivesShowcase() {
-
+
@@ -422,10 +697,20 @@ export function PrimitivesShowcase() {
-
- - - +
+ + +
@@ -434,10 +719,23 @@ export function PrimitivesShowcase() {
- - setModalOpen(false)} title="Sample modal"> -

- Backdrop + escape + click-outside + close button. Radius is 10px per spec. + + setModalOpen(false)} + title="Sample modal" + > +

+ Backdrop + escape + click-outside + close button. Radius is 10px + per spec.

@@ -446,24 +744,35 @@ export function PrimitivesShowcase() { {`### Spec\n\n- bullet\n- another bullet with \`code\`\n\n> Block quote with **bold** and *italic*.`}
-
-
+
+
{ICONS_LIST.map(({ name, node }) => (
{node} - {name} + + {name} +
))}
diff --git a/app/dev/primitives/page.tsx b/app/dev/primitives/page.tsx index 98e2ed7..e21eb37 100644 --- a/app/dev/primitives/page.tsx +++ b/app/dev/primitives/page.tsx @@ -1,14 +1,14 @@ -import { notFound } from 'next/navigation'; -import { PrimitivesShowcase } from './PrimitivesShowcase'; +import { notFound } from "next/navigation"; +import { PrimitivesShowcase } from "./PrimitivesShowcase"; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; /** * Dev-only primitive showcase. Returns 404 outside of `next dev`. * @returns A long page rendering every shared primitive in every state. */ export default function PrimitivesPage() { - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === "production") { notFound(); } return ; diff --git a/app/globals.css b/app/globals.css index 423970e..386cfcd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,7 +4,8 @@ @theme { --font-body: "Inter Variable", "Inter", system-ui, sans-serif; - --font-mono: "GeistMono Variable", "GeistMono", ui-monospace, SFMono-Regular, monospace; + --font-mono: + "GeistMono Variable", "GeistMono", ui-monospace, SFMono-Regular, monospace; /* Dark mode (default) — Raycast near-black blue */ --color-base: #07080a; @@ -13,7 +14,7 @@ --color-surface-raised: #1b1c1e; --color-surface-hover: #252829; --color-border: rgba(255, 255, 255, 0.06); - --color-border-strong: rgba(255, 255, 255, 0.10); + --color-border-strong: rgba(255, 255, 255, 0.1); --color-text-primary: #f9f9f9; --color-text-secondary: #cecece; @@ -25,17 +26,17 @@ --color-accent-glow: rgba(129, 140, 248, 0.12); --color-done: #5fc992; - --color-done-bg: rgba(95, 201, 146, 0.10); + --color-done-bg: rgba(95, 201, 146, 0.1); --color-progress: #ffbc33; - --color-progress-bg: rgba(255, 188, 51, 0.10); + --color-progress-bg: rgba(255, 188, 51, 0.1); --color-todo: #6a6b6c; --color-todo-bg: rgba(106, 107, 108, 0.08); --color-draft: #9ca3af; --color-planned: #55b3ff; --color-cancelled: #e57373; - --color-cancelled-bg: rgba(229, 115, 115, 0.10); - --color-danger: #FF6363; + --color-cancelled-bg: rgba(229, 115, 115, 0.1); + --color-danger: #ff6363; --color-depends: #55b3ff; --color-relates: #a78bfa; @@ -52,9 +53,13 @@ /* Depth shadows — used alongside border-border for the modern hybrid look */ --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-float: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.08); - --shadow-button: inset 0 1px 0 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.08); + --shadow-card-hover: + 0 4px 12px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-float: + 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.08); + --shadow-button: + inset 0 1px 0 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.08); --shadow-glow-accent: 0 0 8px rgba(129, 140, 248, 0.3); --shadow-glow-accent-2: 0 0 8px rgba(110, 231, 216, 0.3); --shadow-glow-done: 0 0 8px rgba(95, 201, 146, 0.3); @@ -68,7 +73,11 @@ /* Brand gradient — declared outside @theme so Tailwind doesn't try to coerce it into a single color utility. */ :root { --color-accent-grad: linear-gradient(135deg, #818cf8 0%, #6ee7d8 100%); - --color-accent-grad-soft: linear-gradient(135deg, rgba(129, 140, 248, 0.18) 0%, rgba(110, 231, 216, 0.14) 100%); + --color-accent-grad-soft: linear-gradient( + 135deg, + rgba(129, 140, 248, 0.18) 0%, + rgba(110, 231, 216, 0.14) 100% + ); } /* =========================================== @@ -92,7 +101,11 @@ html.light { --color-accent-light: #4338ca; --color-accent-glow: rgba(99, 102, 241, 0.08); --color-accent-grad: linear-gradient(135deg, #6366f1 0%, #14b8a6 100%); - --color-accent-grad-soft: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(20, 184, 166, 0.12) 100%); + --color-accent-grad-soft: linear-gradient( + 135deg, + rgba(99, 102, 241, 0.16) 0%, + rgba(20, 184, 166, 0.12) 100% + ); /* Chip palette — saturated dark hues so `text-X` reads on pale `bg-X/15`. */ --color-planned: #2563eb; @@ -118,9 +131,12 @@ html.light { --color-glyph-cancelled: #94a3b8; --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-card-hover: + 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06); --shadow-float: 0 8px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.08); - --shadow-button: inset 0 1px 0 0 rgba(255, 255, 255, 0.8), 0 1px 2px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.08); + --shadow-button: + inset 0 1px 0 0 rgba(255, 255, 255, 0.8), 0 1px 2px rgba(0, 0, 0, 0.06), + 0 0 0 1px rgba(0, 0, 0, 0.08); --shadow-glow-accent-2: 0 0 8px rgba(20, 184, 166, 0.25); } @@ -158,7 +174,11 @@ html { font-family: var(--font-body); font-weight: 500; letter-spacing: 0.005em; - font-feature-settings: "calt" 1, "kern" 1, "liga" 1, "ss03" 1; + font-feature-settings: + "calt" 1, + "kern" 1, + "liga" 1, + "ss03" 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; interpolate-size: allow-keywords; @@ -181,8 +201,11 @@ html:not(.light) body::before { inset: 0; pointer-events: none; z-index: 0; - background: - radial-gradient(ellipse 80% 50% at 15% -10%, rgba(129, 140, 248, 0.05) 0%, transparent 60%); + background: radial-gradient( + ellipse 80% 50% at 15% -10%, + rgba(129, 140, 248, 0.05) 0%, + transparent 60% + ); } /* Light mode: subtle cool texture */ @@ -193,8 +216,16 @@ html.light body::before { pointer-events: none; z-index: 0; background: - radial-gradient(ellipse 80% 50% at 20% 0%, rgba(99, 102, 241, 0.03) 0%, transparent 60%), - radial-gradient(ellipse 50% 40% at 80% 100%, rgba(129, 140, 248, 0.02) 0%, transparent 50%); + radial-gradient( + ellipse 80% 50% at 20% 0%, + rgba(99, 102, 241, 0.03) 0%, + transparent 60% + ), + radial-gradient( + ellipse 50% 40% at 80% 100%, + rgba(129, 140, 248, 0.02) 0%, + transparent 50% + ); } /* =========================================== @@ -228,10 +259,22 @@ html:not(.light) #__next::before { transition: opacity 0.3s ease; } html:not(.light) .glow-card::before { - background: linear-gradient(135deg, rgba(129, 140, 248, 0.15) 0%, transparent 40%, transparent 60%, rgba(99, 102, 241, 0.08) 100%); + background: linear-gradient( + 135deg, + rgba(129, 140, 248, 0.15) 0%, + transparent 40%, + transparent 60%, + rgba(99, 102, 241, 0.08) 100% + ); } html.light .glow-card::before { - background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, transparent 40%, transparent 60%, rgba(129, 140, 248, 0.04) 100%); + background: linear-gradient( + 135deg, + rgba(99, 102, 241, 0.08) 0%, + transparent 40%, + transparent 60%, + rgba(129, 140, 248, 0.04) 100% + ); } .glow-card:hover::before { opacity: 1; @@ -362,7 +405,12 @@ html.light ::-webkit-scrollbar-thumb:hover { =========================================== */ .text-gradient { color: var(--color-accent-light); - background: linear-gradient(135deg, var(--color-accent-light) 0%, var(--color-accent) 50%, #6366f1 100%); + background: linear-gradient( + 135deg, + var(--color-accent-light) 0%, + var(--color-accent) 50%, + #6366f1 100% + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -383,7 +431,13 @@ html.light ::-webkit-scrollbar-thumb:hover { background-clip: padding-box, border-box; background-image: linear-gradient(var(--color-surface), var(--color-surface)), - linear-gradient(135deg, transparent 60%, rgba(129, 140, 248, 0.3) 75%, rgba(99, 102, 241, 0.2) 85%, transparent 95%); + linear-gradient( + 135deg, + transparent 60%, + rgba(129, 140, 248, 0.3) 75%, + rgba(99, 102, 241, 0.2) 85%, + transparent 95% + ); } @supports (background: conic-gradient(from 0deg, red, red)) { @@ -391,13 +445,21 @@ html.light ::-webkit-scrollbar-thumb:hover { --angle: 0deg; background-image: linear-gradient(var(--color-surface), var(--color-surface)), - conic-gradient(from var(--angle), transparent 60%, rgba(129, 140, 248, 0.3) 75%, rgba(99, 102, 241, 0.2) 85%, transparent 95%); + conic-gradient( + from var(--angle), + transparent 60%, + rgba(129, 140, 248, 0.3) 75%, + rgba(99, 102, 241, 0.2) 85%, + transparent 95% + ); animation: spin-border 6s linear infinite; } } @keyframes spin-border { - to { --angle: 360deg; } + to { + --angle: 360deg; + } } /* =========================================== @@ -436,14 +498,22 @@ html.light ::-webkit-scrollbar-thumb:hover { .prose-spec li::marker { color: var(--color-accent); } -.prose-spec h1, .prose-spec h2, .prose-spec h3 { +.prose-spec h1, +.prose-spec h2, +.prose-spec h3 { color: var(--color-text-primary); margin: 0.5em 0 0.2em; font-weight: 600; } -.prose-spec h1 { font-size: 1.1em; } -.prose-spec h2 { font-size: 1em; } -.prose-spec h3 { font-size: 0.95em; } +.prose-spec h1 { + font-size: 1.1em; +} +.prose-spec h2 { + font-size: 1em; +} +.prose-spec h3 { + font-size: 0.95em; +} .prose-spec hr { border: none; border-top: 1px solid var(--color-border); @@ -557,8 +627,15 @@ html.light .prose-spec pre { STATUS PULSE (in_progress tasks) =========================================== */ @keyframes status-pulse { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.35); opacity: 0.6; } + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.35); + opacity: 0.6; + } } .status-pulse { animation: status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; @@ -566,8 +643,12 @@ html.light .prose-spec pre { /* Progress bar shimmer sweep */ @keyframes progress-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(200%); } + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(200%); + } } .progress-shimmer { position: relative; @@ -598,18 +679,33 @@ html.light .progress-shimmer::after { LOADING DOTS =========================================== */ @keyframes gentle-pulse { - 0%, 100% { opacity: 0.4; transform: scale(1); } - 50% { opacity: 1; transform: scale(1.15); } + 0%, + 100% { + opacity: 0.4; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.15); + } } .loading-dot { animation: gentle-pulse 1.2s ease-in-out infinite; } -.loading-dot:nth-child(2) { animation-delay: 150ms; } -.loading-dot:nth-child(3) { animation-delay: 300ms; } +.loading-dot:nth-child(2) { + animation-delay: 150ms; +} +.loading-dot:nth-child(3) { + animation-delay: 300ms; +} @keyframes loading-fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .loading-fade-in { animation: loading-fade-in 220ms ease-out both; @@ -619,7 +715,9 @@ html.light .progress-shimmer::after { REDUCED MOTION =========================================== */ @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { + *, + *::before, + *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; @@ -631,7 +729,9 @@ html.light .progress-shimmer::after { iOS INPUT ZOOM PREVENTION =========================================== */ @supports (-webkit-touch-callout: none) { - input, textarea, select { + input, + textarea, + select { font-size: max(16px, 1em); } } diff --git a/app/layout.tsx b/app/layout.tsx index 6691efd..8404294 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,13 +8,14 @@ import { SessionProvider } from "@/components/providers/SessionProvider"; import "./globals.css"; export const viewport: Viewport = { - width: 'device-width', + width: "device-width", initialScale: 1, }; export const metadata: Metadata = { title: "mymir", - description: "A structure that supports organic growth. Track projects created by your coding agent.", + description: + "A structure that supports organic growth. Track projects created by your coding agent.", }; /** @@ -33,7 +34,11 @@ export default async function RootLayout({ const theme = raw === "light" ? "light" : "dark"; return ( - + diff --git a/app/project/[projectId]/_components/WorkspaceClient.tsx b/app/project/[projectId]/_components/WorkspaceClient.tsx index a431108..47af257 100644 --- a/app/project/[projectId]/_components/WorkspaceClient.tsx +++ b/app/project/[projectId]/_components/WorkspaceClient.tsx @@ -14,10 +14,7 @@ import { useMediaQuery } from "@/hooks/useMediaQuery"; import { DeferredLoadingSpinner } from "@/components/shared/DeferredLoadingSpinner"; import { projectKeys, taskKeys } from "@/lib/query/keys"; import { fetchProjectGraph, fetchTaskBody } from "@/lib/query/queries"; -import type { - ProjectGraphSlim, - TaskGraphSlim, -} from "@/lib/data/views"; +import type { ProjectGraphSlim, TaskGraphSlim } from "@/lib/data/views"; import type { TaskEdge } from "@/lib/db/schema"; /** Workspace view identifier — mirrors the navigator's FilterBar value. */ @@ -88,9 +85,10 @@ export function WorkspaceClient({ projectId }: WorkspaceClientProps) { * when the slim graph no longer contains the selected id (deleted by us * or by another tab via SSE). */ - const selectedTaskSlim: TaskGraphSlim | null = selectedTaskId && graph - ? graph.tasks.find((t) => t.id === selectedTaskId) ?? null - : null; + const selectedTaskSlim: TaskGraphSlim | null = + selectedTaskId && graph + ? (graph.tasks.find((t) => t.id === selectedTaskId) ?? null) + : null; /** * Render-phase reset: when the slim graph has refreshed and the selected @@ -119,7 +117,9 @@ export function WorkspaceClient({ projectId }: WorkspaceClientProps) { else next.set(key, value); const nextQs = next.toString(); if (nextQs === searchParams.toString()) return; - router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false }); + router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { + scroll: false, + }); }, [router, pathname, searchParams], ); @@ -165,8 +165,15 @@ export function WorkspaceClient({ projectId }: WorkspaceClientProps) { } const taskMap = useMemo(() => { - if (!graph) return new Map(); - const map = new Map(); + if (!graph) + return new Map< + string, + { title: string; status: string; taskRef: string } + >(); + const map = new Map< + string, + { title: string; status: string; taskRef: string } + >(); for (const t of graph.tasks) { map.set(t.id, { title: t.title, status: t.status, taskRef: t.taskRef }); } @@ -270,14 +277,30 @@ interface WorkspaceBodyWithSelectionProps extends SharedLayoutProps { * @param props - Layout + selected slim row. * @returns Layout with populated detail and prop rail slots. */ -function WorkspaceBodyWithSelection( - props: WorkspaceBodyWithSelectionProps, -) { - const { projectId, graph, view, isXl, taskSlim, taskMap, projectTags, refreshAll, handleSelectNode, handleClose, drawerOpen, setDrawerOpen, navigatorClosed, setNavigatorClosed, showNavigatorToggle, propRailOpen, setPropRailOpen } = props; +function WorkspaceBodyWithSelection(props: WorkspaceBodyWithSelectionProps) { + const { + projectId, + graph, + view, + isXl, + taskSlim, + taskMap, + projectTags, + refreshAll, + handleSelectNode, + handleClose, + drawerOpen, + setDrawerOpen, + navigatorClosed, + setNavigatorClosed, + showNavigatorToggle, + propRailOpen, + setPropRailOpen, + } = props; // The property-rail toggle is only meaningful inside the graph overlay // (xl + graph view + selection). In structure mode the rail sits beside // the detail column with no overlay to shrink, so the toggle is hidden. - const showPropRailToggle = view === 'graph' && isXl; + const showPropRailToggle = view === "graph" && isXl; const qc = useQueryClient(); const taskId = taskSlim.id; @@ -294,36 +317,36 @@ function WorkspaceBodyWithSelection( [graph.edges, taskId], ); - const taskFullMatches = - selectedTaskFull && selectedTaskFull.id === taskId; + const taskFullMatches = selectedTaskFull && selectedTaskFull.id === taskId; - const detail = taskFullMatches && selectedTaskFull ? ( - setDrawerOpen((v) => !v)} - onClose={handleClose} - onSelectNode={handleSelectNode} - onGraphChange={refreshAll} - navigatorClosed={showNavigatorToggle ? navigatorClosed : undefined} - onToggleNavigator={ - showNavigatorToggle ? () => setNavigatorClosed((v) => !v) : undefined - } - propRailOpen={showPropRailToggle ? propRailOpen : undefined} - onTogglePropRail={ - showPropRailToggle ? () => setPropRailOpen((v) => !v) : undefined - } - /> - ) : ( - - ); + const detail = + taskFullMatches && selectedTaskFull ? ( + setDrawerOpen((v) => !v)} + onClose={handleClose} + onSelectNode={handleSelectNode} + onGraphChange={refreshAll} + navigatorClosed={showNavigatorToggle ? navigatorClosed : undefined} + onToggleNavigator={ + showNavigatorToggle ? () => setNavigatorClosed((v) => !v) : undefined + } + propRailOpen={showPropRailToggle ? propRailOpen : undefined} + onTogglePropRail={ + showPropRailToggle ? () => setPropRailOpen((v) => !v) : undefined + } + /> + ) : ( + + ); const propRail = taskFullMatches && selectedTaskFull ? ( @@ -415,11 +438,11 @@ function WorkspaceLayout(props: WorkspaceLayoutProps) { // and a brief input freeze. Keying an `AnimatePresence` on this value with // `mode="wait"` defers the new tree until the old one has faded out, so the // mount cost is hidden inside the opacity-0 phase of the transition. - const layoutShape: 'graph' | 'xl' | 'narrow' = - view === 'graph' ? 'graph' : isXl ? 'xl' : 'narrow'; + const layoutShape: "graph" | "xl" | "narrow" = + view === "graph" ? "graph" : isXl ? "xl" : "narrow"; let layoutBody: React.ReactNode; - if (layoutShape === 'graph') { + if (layoutShape === "graph") { const showOverlay = isXl && Boolean(taskSlim); layoutBody = (
@@ -439,7 +462,7 @@ function WorkspaceLayout(props: WorkspaceLayoutProps) {
); - } else if (layoutShape === 'xl') { + } else if (layoutShape === "xl") { layoutBody = (
- {inviteCode?.code ?? '—'} + {inviteCode?.code ?? "—"}
- {!isRevoked ? ( @@ -162,7 +169,8 @@ export function InviteCodePanel({
{inviteCode ? (

- Used {inviteCode.useCount} {inviteCode.useCount === 1 ? 'time' : 'times'} + Used {inviteCode.useCount}{" "} + {inviteCode.useCount === 1 ? "time" : "times"} Last updated {formatAbsolute(inviteCode.createdAt)}

diff --git a/app/settings/_components/team-manage/InviteForm.tsx b/app/settings/_components/team-manage/InviteForm.tsx index 8124ae8..3c6e964 100644 --- a/app/settings/_components/team-manage/InviteForm.tsx +++ b/app/settings/_components/team-manage/InviteForm.tsx @@ -1,16 +1,19 @@ -'use client'; +"use client"; -import { useEffect, useRef, useState, useTransition } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { Button } from '@/components/shared/Button'; -import { inviteMemberAction } from '@/lib/actions/team'; +import { useEffect, useRef, useState, useTransition } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { Button } from "@/components/shared/Button"; +import { inviteMemberAction } from "@/lib/actions/team"; const INPUT_CLASS = - 'w-full rounded-lg border border-border-strong bg-base px-4 py-3 text-sm text-text-primary placeholder:text-text-muted outline-none transition-colors focus:border-accent'; + "w-full rounded-lg border border-border-strong bg-base px-4 py-3 text-sm text-text-primary placeholder:text-text-muted outline-none transition-colors focus:border-accent"; -const ROLE_OPTIONS: ReadonlyArray<{ value: 'member' | 'admin'; label: string }> = [ - { value: 'member', label: 'Member' }, - { value: 'admin', label: 'Admin' }, +const ROLE_OPTIONS: ReadonlyArray<{ + value: "member" | "admin"; + label: string; +}> = [ + { value: "member", label: "Member" }, + { value: "admin", label: "Admin" }, ]; interface InviteFormProps { @@ -36,31 +39,35 @@ interface InviteFormProps { * @returns Email + role picker row with Send button. */ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { - const [email, setEmail] = useState(''); - const [role, setRole] = useState<'member' | 'admin'>('member'); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); const [pending, startTransition] = useTransition(); const [roleMenuOpen, setRoleMenuOpen] = useState(false); const roleMenuRef = useRef(null); const trimmed = email.trim(); - const canSubmit = trimmed.length > 0 && trimmed.includes('@') && !pending; - const selectedRoleLabel = ROLE_OPTIONS.find((option) => option.value === role)?.label ?? ''; + const canSubmit = trimmed.length > 0 && trimmed.includes("@") && !pending; + const selectedRoleLabel = + ROLE_OPTIONS.find((option) => option.value === role)?.label ?? ""; useEffect(() => { if (!roleMenuOpen) return; function handleClickOutside(event: MouseEvent) { - if (roleMenuRef.current && !roleMenuRef.current.contains(event.target as Node)) { + if ( + roleMenuRef.current && + !roleMenuRef.current.contains(event.target as Node) + ) { setRoleMenuOpen(false); } } function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') setRoleMenuOpen(false); + if (event.key === "Escape") setRoleMenuOpen(false); } - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); }; }, [roleMenuOpen]); @@ -77,7 +84,7 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { onError(result.message); return; } - setEmail(''); + setEmail(""); await onInvited(); }); }; @@ -105,7 +112,10 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { />
- + Role
@@ -125,10 +135,14 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { stroke="currentColor" strokeWidth="1.5" className={`h-3.5 w-3.5 shrink-0 text-text-muted transition-transform ${ - roleMenuOpen ? 'rotate-180' : '' + roleMenuOpen ? "rotate-180" : "" }`} > - + @@ -155,7 +169,9 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { setRoleMenuOpen(false); }} className={`flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-surface-hover hover:text-text-primary ${ - selected ? 'text-text-primary' : 'text-text-secondary' + selected + ? "text-text-primary" + : "text-text-secondary" }`} > {option.label} @@ -168,7 +184,11 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) { strokeWidth="2" className="h-3.5 w-3.5 text-accent" > - + ) : null} @@ -180,13 +200,19 @@ export function InviteForm({ teamId, onInvited, onError }: InviteFormProps) {
-

- We'll add a row to the pending list. Email delivery ships with the next release; for - now share the invite code below. + We'll add a row to the pending list. Email delivery ships with the + next release; for now share the invite code below.

); diff --git a/app/settings/_components/team-manage/InviteSection.tsx b/app/settings/_components/team-manage/InviteSection.tsx index ce80fc9..4786dba 100644 --- a/app/settings/_components/team-manage/InviteSection.tsx +++ b/app/settings/_components/team-manage/InviteSection.tsx @@ -1,10 +1,10 @@ -'use client'; +"use client"; -import type { InvitationView } from '@/lib/actions/team-invitations-map'; -import type { InviteCodeMetadata } from '@/lib/actions/team-invite-code'; -import { InviteForm } from './InviteForm'; -import { PendingInvitationsList } from './PendingInvitationsList'; -import { InviteCodePanel } from './InviteCodePanel'; +import type { InvitationView } from "@/lib/actions/team-invitations-map"; +import type { InviteCodeMetadata } from "@/lib/actions/team-invite-code"; +import { InviteForm } from "./InviteForm"; +import { PendingInvitationsList } from "./PendingInvitationsList"; +import { InviteCodePanel } from "./InviteCodePanel"; interface InviteSectionProps { /** Team UUID — passed to every target-scoped action. */ diff --git a/app/settings/_components/team-manage/MemberRow.tsx b/app/settings/_components/team-manage/MemberRow.tsx index 42fb454..48f75fe 100644 --- a/app/settings/_components/team-manage/MemberRow.tsx +++ b/app/settings/_components/team-manage/MemberRow.tsx @@ -1,13 +1,13 @@ -'use client'; +"use client"; -import { useEffect, useRef, useState, useTransition } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { initials } from '@/lib/ui/initials'; -import { teamAvatarGradient } from '@/lib/ui/team-avatar'; -import { formatAbsolute } from '@/lib/ui/relative-time'; -import { roleStyle } from '@/lib/ui/role-badge'; -import { removeMemberAction, updateMemberRoleAction } from '@/lib/actions/team'; -import type { MemberView } from '@/lib/actions/team-members-map'; +import { useEffect, useRef, useState, useTransition } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { initials } from "@/lib/ui/initials"; +import { teamAvatarGradient } from "@/lib/ui/team-avatar"; +import { formatAbsolute } from "@/lib/ui/relative-time"; +import { roleStyle } from "@/lib/ui/role-badge"; +import { removeMemberAction, updateMemberRoleAction } from "@/lib/actions/team"; +import type { MemberView } from "@/lib/actions/team-members-map"; interface MemberRowProps { /** Team UUID this row belongs to — required by role/remove actions. */ @@ -23,7 +23,10 @@ interface MemberRowProps { /** Total members in the team — for sole-member safeguard. */ totalMemberCount: number; /** Called after a successful role change with `(memberId, newRole)`. */ - onRoleChanged: (memberId: string, newRole: 'member' | 'admin' | 'owner') => void; + onRoleChanged: ( + memberId: string, + newRole: "member" | "admin" | "owner", + ) => void; /** Called after a successful remove with the member id. */ onRemoved: (memberId: string) => void; /** Surface a transient error message above the list. */ @@ -33,8 +36,8 @@ interface MemberRowProps { } type MenuAction = - | { kind: 'role'; role: 'member' | 'admin' | 'owner'; label: string } - | { kind: 'remove'; label: string }; + | { kind: "role"; role: "member" | "admin" | "owner"; label: string } + | { kind: "remove"; label: string }; /** * Decide which actions the viewer may perform on this target member. @@ -52,29 +55,29 @@ function resolveActions(args: { totalMemberCount: number; }): MenuAction[] { const { target, viewerRole, isSelf, ownerCount, totalMemberCount } = args; - const isAdminViewer = viewerRole === 'admin' || viewerRole === 'owner'; + const isAdminViewer = viewerRole === "admin" || viewerRole === "owner"; if (!isAdminViewer) return []; const actions: MenuAction[] = []; - if (target.role === 'member') { - actions.push({ kind: 'role', role: 'admin', label: 'Promote to admin' }); - if (viewerRole === 'owner') { - actions.push({ kind: 'role', role: 'owner', label: 'Promote to owner' }); + if (target.role === "member") { + actions.push({ kind: "role", role: "admin", label: "Promote to admin" }); + if (viewerRole === "owner") { + actions.push({ kind: "role", role: "owner", label: "Promote to owner" }); } - } else if (target.role === 'admin') { - actions.push({ kind: 'role', role: 'member', label: 'Demote to member' }); - if (viewerRole === 'owner') { - actions.push({ kind: 'role', role: 'owner', label: 'Promote to owner' }); + } else if (target.role === "admin") { + actions.push({ kind: "role", role: "member", label: "Demote to member" }); + if (viewerRole === "owner") { + actions.push({ kind: "role", role: "owner", label: "Promote to owner" }); } - } else if (target.role === 'owner') { - if (viewerRole === 'owner' && ownerCount > 1) { - actions.push({ kind: 'role', role: 'admin', label: 'Demote to admin' }); + } else if (target.role === "owner") { + if (viewerRole === "owner" && ownerCount > 1) { + actions.push({ kind: "role", role: "admin", label: "Demote to admin" }); } } if (!isSelf && totalMemberCount > 1) { - actions.push({ kind: 'remove', label: 'Remove from team' }); + actions.push({ kind: "remove", label: "Remove from team" }); } return actions; @@ -125,20 +128,20 @@ export function MemberRow({ } } function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { + if (event.key === "Escape") { setMenuOpen(false); setConfirmingRemove(false); } } - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); }; }, [menuOpen]); - const handleRoleChange = (next: 'member' | 'admin' | 'owner') => { + const handleRoleChange = (next: "member" | "admin" | "owner") => { setMenuOpen(false); startTransition(async () => { const result = await updateMemberRoleAction({ @@ -179,7 +182,11 @@ export function MemberRow({ ? { opacity: 1, y: 0, - boxShadow: ['var(--shadow-card)', 'var(--shadow-glow-accent)', 'var(--shadow-card)'], + boxShadow: [ + "var(--shadow-card)", + "var(--shadow-glow-accent)", + "var(--shadow-card)", + ], } : { opacity: 1, y: 0 } } @@ -189,7 +196,9 @@ export function MemberRow({ > @@ -231,7 +244,12 @@ export function MemberRow({ aria-label={`Actions for ${member.name}`} className="cursor-pointer rounded-md p-2 text-text-muted transition-colors hover:bg-surface-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40" > - + ) : null} {role.label} diff --git a/app/settings/loading.tsx b/app/settings/loading.tsx index 6f9b88b..00c49af 100644 --- a/app/settings/loading.tsx +++ b/app/settings/loading.tsx @@ -11,14 +11,14 @@ export default function SettingsLoading() {

- Full setup details (auth, updates, self-hosting) in the{' '} + Full setup details (auth, updates, self-hosting) in the{" "}

- mymir projects start in your coding agent. Open it and describe - what you're building. The mymir skill creates the project, and + mymir projects start in your coding agent. Open it and describe what + you're building. The mymir skill creates the project, and it'll show up here once it's active.

@@ -128,12 +130,14 @@ function ReturningBody() {

❯ I want to build a real-time dashboard for server metrics

-

{MULTI_TEAM_HINT}

+

+ {MULTI_TEAM_HINT} +

Setting up another tool, or starting from a fresh machine? Install - commands live in the{' '} + commands live in the{" "}

diff --git a/components/home/NewProjectButton.tsx b/components/home/NewProjectButton.tsx index 09167a5..2ecfdb6 100644 --- a/components/home/NewProjectButton.tsx +++ b/components/home/NewProjectButton.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { Button } from '@/components/shared/Button'; -import { GetStartedModal } from '@/components/home/GetStartedModal'; -import { IconPlus } from '@/components/shared/icons'; +import { useState } from "react"; +import { Button } from "@/components/shared/Button"; +import { GetStartedModal } from "@/components/home/GetStartedModal"; +import { IconPlus } from "@/components/shared/icons"; interface NewProjectButtonProps { /** Switches the modal between first-time and returning copy. */ diff --git a/components/home/ProjectCard.tsx b/components/home/ProjectCard.tsx index c66463b..1446e13 100644 --- a/components/home/ProjectCard.tsx +++ b/components/home/ProjectCard.tsx @@ -1,15 +1,18 @@ -'use client'; +"use client"; -import { useState, useCallback } from 'react'; -import Link from 'next/link'; -import { motion } from 'motion/react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { TeamChip } from '@/components/shared/TeamChip'; -import { ProjectStatusModal, type CliManagedStatus } from '@/components/home/ProjectStatusModal'; -import { IconMore, IconTrash } from '@/components/shared/icons'; -import { projectColor } from '@/lib/ui/project-color'; -import { deleteProjectAction } from '@/lib/actions/project'; -import { projectKeys } from '@/lib/query/keys'; +import { useState, useCallback } from "react"; +import Link from "next/link"; +import { motion } from "motion/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TeamChip } from "@/components/shared/TeamChip"; +import { + ProjectStatusModal, + type CliManagedStatus, +} from "@/components/home/ProjectStatusModal"; +import { IconMore, IconTrash } from "@/components/shared/icons"; +import { projectColor } from "@/lib/ui/project-color"; +import { deleteProjectAction } from "@/lib/actions/project"; +import { projectKeys } from "@/lib/query/keys"; interface ProjectCardProps { /** @param id - Project ID. */ @@ -46,7 +49,7 @@ interface ProjectCardProps { * @returns True for `brainstorming` / `decomposing`. */ function isCliManagedStatus(status: string): status is CliManagedStatus { - return status === 'brainstorming' || status === 'decomposing'; + return status === "brainstorming" || status === "decomposing"; } /** @@ -89,12 +92,13 @@ export function ProjectCard({ setConfirming(false); }, }); - const opensWorkspace = status === 'active' || status === 'archived'; + const opensWorkspace = status === "active" || status === "archived"; const activeTotal = Math.max(totalTasks - cancelledTasks, 0); - const percent = activeTotal > 0 ? Math.round((tasksDone / activeTotal) * 100) : 0; + const percent = + activeTotal > 0 ? Math.round((tasksDone / activeTotal) * 100) : 0; const pending = Math.max(activeTotal - tasksDone - tasksInProgress, 0); const color = projectColor(identifier); - const initial = (identifier[0] ?? title[0] ?? '?').toUpperCase(); + const initial = (identifier[0] ?? title[0] ?? "?").toUpperCase(); /** Two-step delete: first click arms confirmation; second runs the server action via useMutation. */ const handleDelete = useCallback( @@ -129,14 +133,14 @@ export function ProjectCard({ /** Open the CLI-status modal when the user clicks a non-workspace card. */ const handleCardClick = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('button')) return; + if ((e.target as HTMLElement).closest("button")) return; setModalOpen(true); }, []); /** Keyboard equivalent for the CLI-status card click. */ const handleCardKeyDown = useCallback((e: React.KeyboardEvent) => { - if ((e.target as HTMLElement).closest('button')) return; - if (e.key === 'Enter' || e.key === ' ') { + if ((e.target as HTMLElement).closest("button")) return; + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setModalOpen(true); } @@ -145,7 +149,7 @@ export function ProjectCard({ const body = (
@@ -185,7 +189,9 @@ export function ProjectCard({
- {percent}% + + {percent}% + complete @@ -261,7 +267,7 @@ function BrandMark({ initial, color }: BrandMarkProps) { @@ -287,7 +293,12 @@ interface LifecycleBarProps { * @param props - Counts plus the active-task denominator. * @returns Segmented bar with 2px gaps and rounded ends. */ -function LifecycleBar({ done, inProgress, pending, totalActive }: LifecycleBarProps) { +function LifecycleBar({ + done, + inProgress, + pending, + totalActive, +}: LifecycleBarProps) { if (totalActive === 0) { return (
) : null} @@ -232,7 +254,13 @@ interface FilterPillProps { * @param props - Pill configuration. * @returns Tab-style button with an animated active indicator. */ -function FilterPill({ label, active, onClick, disabled, team }: FilterPillProps) { +function FilterPill({ + label, + active, + onClick, + disabled, + team, +}: FilterPillProps) { return ( ); @@ -269,5 +295,10 @@ function FilterPill({ label, active, onClick, disabled, team }: FilterPillProps) */ function TeamDot({ teamId }: { teamId: string }) { const color = getTeamColor(teamId); - return
- +
{children}
diff --git a/components/layout/MotionProvider.tsx b/components/layout/MotionProvider.tsx index d3e90a9..d5f5750 100644 --- a/components/layout/MotionProvider.tsx +++ b/components/layout/MotionProvider.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { MotionConfig } from 'motion/react'; +import { MotionConfig } from "motion/react"; /** * Wraps children with MotionConfig to respect OS reduced-motion preference. diff --git a/components/layout/PageShell.tsx b/components/layout/PageShell.tsx index d24aac3..8e3aeb7 100644 --- a/components/layout/PageShell.tsx +++ b/components/layout/PageShell.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { type ReactNode } from 'react'; +import { type ReactNode } from "react"; interface PageShellProps { /** @param children - Page content. */ @@ -15,10 +15,12 @@ interface PageShellProps { * @param props - Page shell configuration. * @returns A scrollable content container element with a centered max-width inner. */ -export function PageShell({ children, className = '' }: PageShellProps) { +export function PageShell({ children, className = "" }: PageShellProps) { return (
-
+
{children}
diff --git a/components/layout/ProjectBreadcrumb.tsx b/components/layout/ProjectBreadcrumb.tsx index aa3f4c7..9a26682 100644 --- a/components/layout/ProjectBreadcrumb.tsx +++ b/components/layout/ProjectBreadcrumb.tsx @@ -1,14 +1,37 @@ -'use client'; +"use client"; -import { motion } from 'motion/react'; -import { TeamChip } from '@/components/shared/TeamChip'; +import { motion } from "motion/react"; +import { TeamChip } from "@/components/shared/TeamChip"; /** Status chip display mapping — mirrors the home ProjectCard. */ -const PROJECT_STATUS_DISPLAY: Record = { - brainstorming: { label: 'Idea', dot: 'bg-accent', bg: 'bg-accent/15', text: 'text-accent' }, - decomposing: { label: 'Building', dot: 'bg-progress', bg: 'bg-progress/15', text: 'text-progress' }, - active: { label: 'Active', dot: 'bg-done', bg: 'bg-done/15', text: 'text-done' }, - archived: { label: 'Archived', dot: 'bg-draft', bg: 'bg-draft/10', text: 'text-draft' }, +const PROJECT_STATUS_DISPLAY: Record< + string, + { label: string; dot: string; bg: string; text: string } +> = { + brainstorming: { + label: "Idea", + dot: "bg-accent", + bg: "bg-accent/15", + text: "text-accent", + }, + decomposing: { + label: "Building", + dot: "bg-progress", + bg: "bg-progress/15", + text: "text-progress", + }, + active: { + label: "Active", + dot: "bg-done", + bg: "bg-done/15", + text: "text-done", + }, + archived: { + label: "Archived", + dot: "bg-draft", + bg: "bg-draft/10", + text: "text-draft", + }, }; interface ProjectBreadcrumbProps { @@ -30,7 +53,12 @@ interface ProjectBreadcrumbProps { * @param props - Breadcrumb configuration. * @returns Button displaying the team chip, project name, status chip, and pencil icon. */ -export function ProjectBreadcrumb({ projectName, projectStatus, team, onOpenSettings }: ProjectBreadcrumbProps) { +export function ProjectBreadcrumb({ + projectName, + projectStatus, + team, + onOpenSettings, +}: ProjectBreadcrumbProps) { return ( {team && } - {projectName} + + {projectName} + {projectStatus && PROJECT_STATUS_DISPLAY[projectStatus] && ( - + {PROJECT_STATUS_DISPLAY[projectStatus].label} )} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index acf2b8d..2a3a501 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -1,14 +1,14 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import { signOut } from '@/lib/auth-client'; -import { Avatar } from '@/components/shared/Avatar'; -import { Kbd } from '@/components/shared/Kbd'; -import { projectColor } from '@/lib/ui/project-color'; -import { getTeamColor } from '@/lib/ui/team-color'; -import { useSidebarCollapse } from '@/components/layout/SidebarCollapseProvider'; +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; +import { signOut } from "@/lib/auth-client"; +import { Avatar } from "@/components/shared/Avatar"; +import { Kbd } from "@/components/shared/Kbd"; +import { projectColor } from "@/lib/ui/project-color"; +import { getTeamColor } from "@/lib/ui/team-color"; +import { useSidebarCollapse } from "@/components/layout/SidebarCollapseProvider"; import { IconChevronRight, IconInbox, @@ -17,7 +17,7 @@ import { IconSearch, IconSettings, IconUser, -} from '@/components/shared/icons'; +} from "@/components/shared/icons"; /** Slim user shape the sidebar needs — fed by AppShell from `getSession()`. */ export interface SidebarUser { @@ -71,9 +71,14 @@ interface SidebarProps { * @param props - User, workspace label, and project list. * @returns Aside element styled per the design spec. */ -export function Sidebar({ user, workspaceLabel, projects, teams }: SidebarProps) { +export function Sidebar({ + user, + workspaceLabel, + projects, + teams, +}: SidebarProps) { const [openProjects, setOpenProjects] = useState(true); - const pathname = usePathname() ?? '/'; + const pathname = usePathname() ?? "/"; const activeProjectId = pathname.match(/^\/project\/([^/]+)/)?.[1]; const { collapsed, toggle } = useSidebarCollapse(); @@ -81,7 +86,9 @@ export function Sidebar({ user, workspaceLabel, projects, teams }: SidebarProps) () => groupProjectsByTeam(projects, teams), [projects, teams], ); - const [collapsedTeams, setCollapsedTeams] = useState>(() => new Set()); + const [collapsedTeams, setCollapsedTeams] = useState>( + () => new Set(), + ); const toggleTeam = useCallback((teamKey: string) => { setCollapsedTeams((prev) => { const next = new Set(prev); @@ -95,9 +102,9 @@ export function Sidebar({ user, workspaceLabel, projects, teams }: SidebarProps)
@@ -160,7 +175,9 @@ export function Sidebar({ user, workspaceLabel, projects, teams }: SidebarProps) > @@ -171,7 +188,9 @@ export function Sidebar({ user, workspaceLabel, projects, teams }: SidebarProps) {openProjects && (
{projects.length === 0 ? ( -

No projects yet

+

+ No projects yet +

) : projectGroups.length === 1 ? ( projectGroups[0].projects.map((p) => ( } - + )} @@ -247,7 +269,7 @@ function CompactSidebar({ /** Sign out and bounce to the sign-in page. */ const handleSignOut = async () => { await signOut(); - router.replace('/sign-in'); + router.replace("/sign-in"); }; return ( @@ -272,9 +294,21 @@ function CompactSidebar({
@@ -288,14 +322,14 @@ function CompactSidebar({ href={`/project/${p.id}`} title={`${p.title} · ${p.identifier}`} className={`relative flex h-7 w-7 items-center justify-center rounded-md transition-colors ${ - active ? 'bg-surface-hover' : 'hover:bg-surface-hover/60' + active ? "bg-surface-hover" : "hover:bg-surface-hover/60" }`} > {active && (
+
{group.projects.map((p) => ( {project.title} - {project.identifier} + + {project.identifier} + ); } @@ -595,7 +642,7 @@ function UserFooter({ user, settingsActive }: UserFooterProps) { /** Sign out and redirect to sign-in page. */ const handleSignOut = async () => { await signOut(); - router.replace('/sign-in'); + router.replace("/sign-in"); }; const displayName = user.name?.trim() || user.email; @@ -624,8 +671,8 @@ function UserFooter({ user, settingsActive }: UserFooterProps) { title="Settings" className={`inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded transition-colors ${ settingsActive - ? 'bg-surface-hover text-text-primary' - : 'text-text-muted hover:bg-surface-hover hover:text-text-secondary' + ? "bg-surface-hover text-text-primary" + : "text-text-muted hover:bg-surface-hover hover:text-text-secondary" }`} > diff --git a/components/layout/SidebarCollapseProvider.tsx b/components/layout/SidebarCollapseProvider.tsx index ce4aa97..d73d339 100644 --- a/components/layout/SidebarCollapseProvider.tsx +++ b/components/layout/SidebarCollapseProvider.tsx @@ -1,4 +1,4 @@ -'use client'; +"use client"; import { createContext, @@ -6,10 +6,10 @@ import { useContext, useSyncExternalStore, type ReactNode, -} from 'react'; +} from "react"; /** Cookie name for the sidebar collapsed-state preference. Server-readable so SSR can render the correct width on first paint and avoid a hydration flash. */ -const COOKIE_NAME = 'mymir-sidebar-collapsed'; +const COOKIE_NAME = "mymir-sidebar-collapsed"; /** Cookie max-age in seconds (1 year). */ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; @@ -37,7 +37,7 @@ function readCookie(): boolean { const match = document.cookie.match( new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`), ); - return match?.[1] === '1'; + return match?.[1] === "1"; } catch { return false; } @@ -50,7 +50,7 @@ function readCookie(): boolean { */ function writeCookie(next: boolean): void { try { - document.cookie = `${COOKIE_NAME}=${next ? '1' : '0'}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`; + document.cookie = `${COOKIE_NAME}=${next ? "1" : "0"}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`; } catch { /* swallow cookie errors — preference is non-critical */ } diff --git a/components/layout/ThemeProvider.tsx b/components/layout/ThemeProvider.tsx index 06f48a4..f557d9d 100644 --- a/components/layout/ThemeProvider.tsx +++ b/components/layout/ThemeProvider.tsx @@ -1,9 +1,14 @@ -'use client'; +"use client"; -import { createContext, useContext, useSyncExternalStore, useCallback } from 'react'; -import { subscribeTheme, getTheme, setTheme as applyTheme } from '@/lib/theme'; +import { + createContext, + useContext, + useSyncExternalStore, + useCallback, +} from "react"; +import { subscribeTheme, getTheme, setTheme as applyTheme } from "@/lib/theme"; -type Theme = 'light' | 'dark'; +type Theme = "light" | "dark"; interface ThemeContextValue { /** @returns Current theme. */ @@ -13,7 +18,7 @@ interface ThemeContextValue { } const ThemeContext = createContext({ - theme: 'dark', + theme: "dark", setTheme: () => {}, }); @@ -31,17 +36,17 @@ interface ThemeProviderProps { * @returns Context provider wrapping children. */ export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) { - const theme = useSyncExternalStore(subscribeTheme, getTheme, () => initialTheme); + const theme = useSyncExternalStore( + subscribeTheme, + getTheme, + () => initialTheme, + ); const setTheme = useCallback((next: Theme) => { applyTheme(next); }, []); - return ( - - {children} - - ); + return {children}; } /** diff --git a/components/layout/TopBar.tsx b/components/layout/TopBar.tsx index 04782d2..9575aa7 100644 --- a/components/layout/TopBar.tsx +++ b/components/layout/TopBar.tsx @@ -1,14 +1,10 @@ -'use client'; +"use client"; -import { usePathname } from 'next/navigation'; -import { useTheme } from '@/components/layout/ThemeProvider'; -import { ProjectBreadcrumb } from '@/components/layout/ProjectBreadcrumb'; -import { Kbd } from '@/components/shared/Kbd'; -import { - IconMoon, - IconSearch, - IconSun, -} from '@/components/shared/icons'; +import { usePathname } from "next/navigation"; +import { useTheme } from "@/components/layout/ThemeProvider"; +import { ProjectBreadcrumb } from "@/components/layout/ProjectBreadcrumb"; +import { Kbd } from "@/components/shared/Kbd"; +import { IconMoon, IconSearch, IconSun } from "@/components/shared/icons"; interface TopBarProps { /** @param projectName - Optional project crumb label. When set, renders the project breadcrumb pill. */ @@ -43,23 +39,30 @@ export function TopBar({ pageLabel, }: TopBarProps) { const { theme, setTheme } = useTheme(); - const pathname = usePathname() ?? '/'; + const pathname = usePathname() ?? "/"; - const derivedPageLabel = derivePageLabel({ projectName, pageLabel, pathname }); + const derivedPageLabel = derivePageLabel({ + projectName, + pageLabel, + pathname, + }); /** Toggle between light and dark theme and persist the choice. */ const toggleTheme = () => { - setTheme(theme === 'dark' ? 'light' : 'dark'); + setTheme(theme === "dark" ? "light" : "dark"); }; return (
-
@@ -117,11 +126,15 @@ interface DerivePageLabelInput { * @param input - Project name override, explicit page label, current path. * @returns Crumb label or null when nothing should render. */ -function derivePageLabel({ projectName, pageLabel, pathname }: DerivePageLabelInput): string | null { +function derivePageLabel({ + projectName, + pageLabel, + pathname, +}: DerivePageLabelInput): string | null { if (projectName) return null; if (pageLabel) return pageLabel; - if (pathname === '/') return 'Projects'; - if (pathname.startsWith('/settings')) return 'Settings'; + if (pathname === "/") return "Projects"; + if (pathname.startsWith("/settings")) return "Settings"; return null; } diff --git a/components/layout/TwoPanelLayout.tsx b/components/layout/TwoPanelLayout.tsx index 7a702a6..02fcbed 100644 --- a/components/layout/TwoPanelLayout.tsx +++ b/components/layout/TwoPanelLayout.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useState } from "react"; interface TwoPanelLayoutProps { /** @param left - Content for the left panel. */ @@ -10,7 +10,7 @@ interface TwoPanelLayoutProps { /** @param className - Additional CSS classes. */ className?: string; /** @param activePanelHint - When changed, auto-switches to this panel on mobile. */ - activePanelHint?: 'left' | 'right'; + activePanelHint?: "left" | "right"; } /** @@ -23,10 +23,12 @@ interface TwoPanelLayoutProps { export function TwoPanelLayout({ left, right, - className = '', + className = "", activePanelHint, }: TwoPanelLayoutProps) { - const [activePanel, setActivePanel] = useState<'left' | 'right'>(activePanelHint ?? 'left'); + const [activePanel, setActivePanel] = useState<"left" | "right">( + activePanelHint ?? "left", + ); const [prevHint, setPrevHint] = useState(activePanelHint); if (activePanelHint !== prevHint) { @@ -37,12 +39,21 @@ export function TwoPanelLayout({ } return ( -
+
{/* Desktop: side-by-side */}
-
{left}
+
+ {left} +
-
{right}
+
+ {right} +
{/* Mobile: toggle bar + single panel */} @@ -50,17 +61,17 @@ export function TwoPanelLayout({
setActivePanel('left')} + active={activePanel === "left"} + onClick={() => setActivePanel("left")} /> setActivePanel('right')} + active={activePanel === "right"} + onClick={() => setActivePanel("right")} />
- {activePanel === 'left' ? left : right} + {activePanel === "left" ? left : right}
@@ -87,8 +98,8 @@ function ToggleTab({ onClick={onClick} className={`flex-1 py-2.5 text-xs sm:text-sm font-medium transition-opacity ${ active - ? 'text-text-primary border-b-2 border-accent' - : 'text-text-muted hover:opacity-60' + ? "text-text-primary border-b-2 border-accent" + : "text-text-muted hover:opacity-60" }`} > {label} diff --git a/components/layout/WorkspaceLabelProvider.tsx b/components/layout/WorkspaceLabelProvider.tsx index 5663e30..007b8a7 100644 --- a/components/layout/WorkspaceLabelProvider.tsx +++ b/components/layout/WorkspaceLabelProvider.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { createContext, useContext, type ReactNode } from 'react'; +import { createContext, useContext, type ReactNode } from "react"; const WorkspaceLabelContext = createContext(null); @@ -19,8 +19,13 @@ interface WorkspaceLabelProviderProps { * @param props - Provider configuration. * @returns Context provider element. */ -export function WorkspaceLabelProvider({ value, children }: WorkspaceLabelProviderProps) { - return {children}; +export function WorkspaceLabelProvider({ + value, + children, +}: WorkspaceLabelProviderProps) { + return ( + {children} + ); } /** diff --git a/components/providers/QueryProvider.tsx b/components/providers/QueryProvider.tsx index fc487d7..1592ff1 100644 --- a/components/providers/QueryProvider.tsx +++ b/components/providers/QueryProvider.tsx @@ -23,7 +23,10 @@ interface QueryProviderProps { * @param props - Children and optional dehydrated state. * @returns Provider tree wrapping children. */ -export function QueryProvider({ children, dehydratedState }: QueryProviderProps) { +export function QueryProvider({ + children, + dehydratedState, +}: QueryProviderProps) { const [client] = useState(() => getBrowserQueryClient()); return ( diff --git a/components/providers/SessionProvider.tsx b/components/providers/SessionProvider.tsx index 3b73fed..fd181ad 100644 --- a/components/providers/SessionProvider.tsx +++ b/components/providers/SessionProvider.tsx @@ -32,7 +32,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { } }, [session.isPending, session.data, pathname, router]); - const shouldHide = !session.isPending && !session.data && !PUBLIC_PATHS.includes(pathname); + const shouldHide = + !session.isPending && !session.data && !PUBLIC_PATHS.includes(pathname); return ( diff --git a/components/shared/AutoGrowTextarea.tsx b/components/shared/AutoGrowTextarea.tsx index c772f55..2c8a288 100644 --- a/components/shared/AutoGrowTextarea.tsx +++ b/components/shared/AutoGrowTextarea.tsx @@ -1,7 +1,10 @@ -'use client'; +"use client"; -import { useEffect, useRef } from 'react'; -import type { InputEvent as ReactInputEvent, TextareaHTMLAttributes } from 'react'; +import { useEffect, useRef } from "react"; +import type { + InputEvent as ReactInputEvent, + TextareaHTMLAttributes, +} from "react"; type AutoGrowTextareaProps = TextareaHTMLAttributes; @@ -18,12 +21,12 @@ export function AutoGrowTextarea({ onInput, ...rest }: AutoGrowTextareaProps) { const resize = () => { const el = ref.current; if (!el) return; - el.style.height = 'auto'; + el.style.height = "auto"; const cssMax = parseFloat(getComputedStyle(el).maxHeight); const cap = Number.isFinite(cssMax) ? cssMax : DEFAULT_MAX_HEIGHT_PX; const target = Math.min(el.scrollHeight, cap); el.style.height = `${target}px`; - el.style.overflowY = el.scrollHeight > target ? 'auto' : 'hidden'; + el.style.overflowY = el.scrollHeight > target ? "auto" : "hidden"; }; useEffect(() => { diff --git a/components/shared/Avatar.tsx b/components/shared/Avatar.tsx index 017ff45..695b359 100644 --- a/components/shared/Avatar.tsx +++ b/components/shared/Avatar.tsx @@ -1,10 +1,10 @@ /** Deterministic 5-stop palette used to colour initial-only avatars. */ const AVATAR_PALETTE: ReadonlyArray = [ - ['#818cf8', '#6ee7d8'], - ['#f472b6', '#fb923c'], - ['#34d399', '#60a5fa'], - ['#fbbf24', '#f87171'], - ['#a78bfa', '#22d3ee'], + ["#818cf8", "#6ee7d8"], + ["#f472b6", "#fb923c"], + ["#34d399", "#60a5fa"], + ["#fbbf24", "#f87171"], + ["#a78bfa", "#22d3ee"], ]; interface AvatarProps { @@ -42,8 +42,11 @@ function paletteIndex(name: string): number { */ function initialsFor(name: string): string { const parts = name.split(/\s+/).filter(Boolean).slice(0, 2); - if (parts.length === 0) return '?'; - return parts.map((p) => p[0]).join('').toUpperCase(); + if (parts.length === 0) return "?"; + return parts + .map((p) => p[0]) + .join("") + .toUpperCase(); } /** @@ -51,14 +54,21 @@ function initialsFor(name: string): string { * @param props - Avatar configuration. * @returns A round avatar element sized via `size`. */ -export function Avatar({ name, src, size = 22, ring = false, accent = false, className = '' }: AvatarProps) { - const safeName = (name ?? '').trim() || '?'; +export function Avatar({ + name, + src, + size = 22, + ring = false, + accent = false, + className = "", +}: AvatarProps) { + const safeName = (name ?? "").trim() || "?"; const idx = paletteIndex(safeName); const [c1, c2] = AVATAR_PALETTE[idx]; const initials = initialsFor(safeName); const background = accent - ? 'var(--color-accent-grad)' + ? "var(--color-accent-grad)" : `linear-gradient(135deg, ${c1}, ${c2})`; if (src) { @@ -69,16 +79,16 @@ export function Avatar({ name, src, size = 22, ring = false, accent = false, cla width: size, height: size, flexShrink: 0, - boxShadow: ring ? '0 0 0 2px var(--color-surface)' : undefined, + boxShadow: ring ? "0 0 0 2px var(--color-surface)" : undefined, }} > {/* eslint-disable-next-line @next/next/no-img-element -- avatar images are 18-56px, next/image optimization is overkill and external OAuth avatars require remote pattern config */} {safeName ); @@ -87,18 +97,18 @@ export function Avatar({ name, src, size = 22, ring = false, accent = false, cla return ( {initials} diff --git a/components/shared/Badge.tsx b/components/shared/Badge.tsx index 9d7626d..7c7ea64 100644 --- a/components/shared/Badge.tsx +++ b/components/shared/Badge.tsx @@ -1,19 +1,35 @@ -'use client'; +"use client"; -import { StatusGlyph, STATUS_META, type TaskStatus } from './StatusGlyph'; +import { StatusGlyph, STATUS_META, type TaskStatus } from "./StatusGlyph"; interface EdgeMeta { label: string; cssVar: string; /** Tiny dot, no glyph. */ - glyph: 'dot'; + glyph: "dot"; } const EDGE_META: Record = { - depends_on: { label: 'Depends on', cssVar: 'var(--color-depends)', glyph: 'dot' }, - relates_to: { label: 'Relates to', cssVar: 'var(--color-relates)', glyph: 'dot' }, - blocks: { label: 'Blocks', cssVar: 'var(--color-glyph-blocked)', glyph: 'dot' }, - parent_of: { label: 'Parent of', cssVar: 'var(--color-text-muted)', glyph: 'dot' }, + depends_on: { + label: "Depends on", + cssVar: "var(--color-depends)", + glyph: "dot", + }, + relates_to: { + label: "Relates to", + cssVar: "var(--color-relates)", + glyph: "dot", + }, + blocks: { + label: "Blocks", + cssVar: "var(--color-glyph-blocked)", + glyph: "dot", + }, + parent_of: { + label: "Parent of", + cssVar: "var(--color-text-muted)", + glyph: "dot", + }, }; interface BadgeProps { @@ -31,14 +47,15 @@ interface BadgeProps { * @param props - Badge props with status and optional className. * @returns A pill-shaped span coloured by the status' glyph variable. */ -export function Badge({ status, dense = false, className = '' }: BadgeProps) { +export function Badge({ status, dense = false, className = "" }: BadgeProps) { const taskMeta = STATUS_META[status as TaskStatus]; const edgeMeta = !taskMeta ? EDGE_META[status] : undefined; const meta = taskMeta ?? edgeMeta; - const label = meta?.label ?? status.replace(/[_-]/g, ' '); - const color = taskMeta?.cssVar ?? edgeMeta?.cssVar ?? 'var(--color-text-muted)'; - const isCancelled = status === 'cancelled'; + const label = meta?.label ?? status.replace(/[_-]/g, " "); + const color = + taskMeta?.cssVar ?? edgeMeta?.cssVar ?? "var(--color-text-muted)"; + const isCancelled = status === "cancelled"; return ( {taskMeta ? ( ) : ( - ) : ( <> - {icon ? {icon} : null} - {children} + {icon ? ( + {icon} + ) : null} + + {children} + {kbd ? {kbd} : null} )} diff --git a/components/shared/Card.tsx b/components/shared/Card.tsx index 53232a8..4edf695 100644 --- a/components/shared/Card.tsx +++ b/components/shared/Card.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { motion } from 'motion/react'; -import { type ReactNode, type KeyboardEvent } from 'react'; +import { motion } from "motion/react"; +import { type ReactNode, type KeyboardEvent } from "react"; interface CardProps { /** @param hover - Enable border + shadow + accent glow on hover. */ @@ -18,8 +18,10 @@ interface CardProps { onClick?: () => void; } -const baseClasses = 'bg-surface border border-border rounded-lg shadow-[var(--shadow-card)] transition-all'; -const hoverClasses = 'hover:border-border-strong hover:shadow-[var(--shadow-card-hover)] glow-card'; +const baseClasses = + "bg-surface border border-border rounded-lg shadow-[var(--shadow-card)] transition-all"; +const hoverClasses = + "hover:border-border-strong hover:shadow-[var(--shadow-card-hover)] glow-card"; /** * Card container with optional accent-tinted glow hover and entrance animation. @@ -30,24 +32,30 @@ export function Card({ hover = false, animated = false, padded = false, - className = '', + className = "", children, onClick, }: CardProps) { - const padClass = padded ? 'p-4' : ''; - const cursorClass = onClick ? 'cursor-pointer' : ''; - const classes = `${baseClasses} ${hover ? hoverClasses : ''} ${padClass} ${cursorClass} ${className}`.trim(); + const padClass = padded ? "p-4" : ""; + const cursorClass = onClick ? "cursor-pointer" : ""; + const classes = + `${baseClasses} ${hover ? hoverClasses : ""} ${padClass} ${cursorClass} ${className}`.trim(); const interactive = !!onClick; const handleKeyDown = (e: KeyboardEvent) => { - if (onClick && (e.key === 'Enter' || e.key === ' ')) { + if (onClick && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onClick(); } }; const a11yProps = interactive - ? { role: 'button' as const, tabIndex: 0, onKeyDown: handleKeyDown, 'aria-label': 'Interactive card' } + ? { + role: "button" as const, + tabIndex: 0, + onKeyDown: handleKeyDown, + "aria-label": "Interactive card", + } : {}; if (animated) { @@ -55,7 +63,7 @@ export function Card({ diff --git a/components/shared/Checkbox.tsx b/components/shared/Checkbox.tsx index 065b61b..587fcc3 100644 --- a/components/shared/Checkbox.tsx +++ b/components/shared/Checkbox.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { motion } from 'motion/react'; +import { motion } from "motion/react"; interface CheckboxProps { /** @param checked - Whether the checkbox is checked. */ @@ -20,14 +20,22 @@ interface CheckboxProps { * @param props - Checkbox configuration props. * @returns A styled checkbox element. */ -export function Checkbox({ checked, onChange, label, className = '', size = 16 }: CheckboxProps) { +export function Checkbox({ + checked, + onChange, + label, + className = "", + size = 16, +}: CheckboxProps) { return ( -
) : ( @@ -345,18 +436,29 @@ interface BundleSectionProps { * @param section - Section configuration. * @returns Section element with header and animated body. */ -function BundleSection({ id, open, isLast, onToggle, props, onSelectTask }: BundleSectionProps) { +function BundleSection({ + id, + open, + isLast, + onToggle, + props, + onSelectTask, +}: BundleSectionProps) { const meta = SECTION_META[id]; const summary = sectionSummary(id, props); return ( -
+
@@ -176,7 +195,12 @@ export function PlanSection({
} + badge={ + + } trailing={ @@ -195,21 +219,31 @@ export function PlanSection({ />
- {plan} + + {plan} +
{execution ? ( - {execution} + + {execution} + ) : !started ? (

- Plan saved. Start implementation to claim this task and begin tracking execution. + Plan saved. Start implementation to claim this task and begin + tracking execution.

-
) : ( @@ -224,8 +258,14 @@ export function PlanSection({ rows={5} className="w-full resize-none rounded-md border border-border-strong bg-surface px-3 py-2 font-mono text-[11.5px] text-text-primary placeholder:text-text-muted outline-none focus:border-accent" /> - )} @@ -254,8 +294,13 @@ function UnmetDepsHint({ deps }: UnmetDepsHintProps) {

    {deps.map((d) => ( -
  • - {d.taskRef} +
  • + + {d.taskRef} + {d.title}
  • ))} @@ -268,7 +313,7 @@ interface PhaseBadgeProps { /** Badge label text. */ label: string; /** Tone keyed to status palette. */ - tone: 'draft' | 'planned' | 'progress' | 'done'; + tone: "draft" | "planned" | "progress" | "done"; } /** @@ -279,14 +324,28 @@ interface PhaseBadgeProps { */ function PhaseBadge({ label, tone }: PhaseBadgeProps) { const map = { - draft: { fg: 'text-text-muted', bg: 'bg-text-muted/10', border: 'border-text-muted/20' }, - planned: { fg: 'text-planned', bg: 'bg-planned/10', border: 'border-planned/25' }, - progress: { fg: 'text-progress', bg: 'bg-progress/10', border: 'border-progress/25' }, - done: { fg: 'text-done', bg: 'bg-done/10', border: 'border-done/25' }, + draft: { + fg: "text-text-muted", + bg: "bg-text-muted/10", + border: "border-text-muted/20", + }, + planned: { + fg: "text-planned", + bg: "bg-planned/10", + border: "border-planned/25", + }, + progress: { + fg: "text-progress", + bg: "bg-progress/10", + border: "border-progress/25", + }, + done: { fg: "text-done", bg: "bg-done/10", border: "border-done/25" }, } as const; const cls = map[tone]; return ( - + {label} ); @@ -294,7 +353,7 @@ function PhaseBadge({ label, tone }: PhaseBadgeProps) { interface PhaseCardProps { /** Tone keyed to status palette. */ - tone: 'planned' | 'progress' | 'done'; + tone: "planned" | "progress" | "done"; /** Card title. */ title: string; /** Card body. */ @@ -310,15 +369,26 @@ interface PhaseCardProps { */ function PhaseCard({ tone, title, children }: PhaseCardProps) { const map = { - planned: { ring: 'border-planned/20', bg: 'bg-planned/5', fg: 'text-planned' }, - progress: { ring: 'border-progress/20', bg: 'bg-progress/5', fg: 'text-progress' }, - done: { ring: 'border-done/20', bg: 'bg-done/5', fg: 'text-done' }, + planned: { + ring: "border-planned/20", + bg: "bg-planned/5", + fg: "text-planned", + }, + progress: { + ring: "border-progress/20", + bg: "bg-progress/5", + fg: "text-progress", + }, + done: { ring: "border-done/20", bg: "bg-done/5", fg: "text-done" }, } as const; const cls = map[tone]; return (
    -
    {children} diff --git a/components/workspace/detail/PropRail.tsx b/components/workspace/detail/PropRail.tsx index 021febd..2ea9765 100644 --- a/components/workspace/detail/PropRail.tsx +++ b/components/workspace/detail/PropRail.tsx @@ -1,21 +1,21 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { AnimatePresence, motion } from 'motion/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Avatar } from '@/components/shared/Avatar'; -import { Markdown } from '@/components/shared/Markdown'; -import { MonoId } from '@/components/shared/MonoId'; -import { PriorityIcon } from '@/components/shared/PriorityIcon'; -import { StatusGlyph, STATUS_META } from '@/components/shared/StatusGlyph'; -import { Dropdown } from '@/components/shared/Dropdown'; -import { useUndo, UndoButton } from '@/hooks/useUndo'; -import { popoverFixedStyle, usePopoverAnchor } from '@/hooks/usePopoverAnchor'; -import { updateTask } from '@/lib/graph/mutations'; -import { projectColor } from '@/lib/ui/project-color'; -import { listTeamMembersAction } from '@/lib/actions/team-members'; -import type { MemberView } from '@/lib/actions/team-members-map'; +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "motion/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Avatar } from "@/components/shared/Avatar"; +import { Markdown } from "@/components/shared/Markdown"; +import { MonoId } from "@/components/shared/MonoId"; +import { PriorityIcon } from "@/components/shared/PriorityIcon"; +import { StatusGlyph, STATUS_META } from "@/components/shared/StatusGlyph"; +import { Dropdown } from "@/components/shared/Dropdown"; +import { useUndo, UndoButton } from "@/hooks/useUndo"; +import { popoverFixedStyle, usePopoverAnchor } from "@/hooks/usePopoverAnchor"; +import { updateTask } from "@/lib/graph/mutations"; +import { projectColor } from "@/lib/ui/project-color"; +import { listTeamMembersAction } from "@/lib/actions/team-members"; +import type { MemberView } from "@/lib/actions/team-members-map"; import { IconBranch, IconChevronDown, @@ -27,10 +27,10 @@ import { IconTag, IconUser, IconX, -} from '@/components/shared/icons'; -import type { TaskEdge } from '@/lib/db/schema'; -import type { Priority, Estimate, TaskStatus } from '@/lib/types'; -import type { AssigneeRef, ProjectGraphSlim, TaskFull } from '@/lib/data/views'; +} from "@/components/shared/icons"; +import type { TaskEdge } from "@/lib/db/schema"; +import type { Priority, Estimate, TaskStatus } from "@/lib/types"; +import type { AssigneeRef, ProjectGraphSlim, TaskFull } from "@/lib/data/views"; /** * Subset of task fields safe to patch onto both the `TaskFull` and @@ -46,15 +46,22 @@ type TaskPatch = Partial<{ category: string | null; tags: string[]; }>; -import { PRIORITY_COLOR, PRIORITY_DISPLAY_ORDER } from '@/lib/ui/priority'; -import { projectKeys, taskKeys, teamKeys } from '@/lib/query/keys'; +import { PRIORITY_COLOR, PRIORITY_DISPLAY_ORDER } from "@/lib/ui/priority"; +import { projectKeys, taskKeys, teamKeys } from "@/lib/query/keys"; /** Display order for the Status dropdown — matches the lifecycle ribbon. */ -const STATUS_OPTIONS: readonly TaskStatus[] = ['draft', 'planned', 'in_progress', 'in_review', 'done', 'cancelled']; +const STATUS_OPTIONS: readonly TaskStatus[] = [ + "draft", + "planned", + "in_progress", + "in_review", + "done", + "cancelled", +]; /** Display order for the Estimate dropdown — Fibonacci story points. */ const ESTIMATE_OPTIONS: readonly Estimate[] = [1, 2, 3, 5, 8, 13]; /** Sentinel used by dropdowns to model the "clear" action under `string` schemas. */ -const SENTINEL_CLEAR = '__clear__'; +const SENTINEL_CLEAR = "__clear__"; interface PropRailProps { /** Task UUID. */ @@ -128,15 +135,11 @@ export function PropRail({ // an O(edges) hit on every refetch even when nothing relevant changed. // Centralising the pass also gives `DepGroup` a stable `items` prop so a // future `React.memo` on the sub-row can short-circuit. - const { - dependsOnItems, - blocksItems, - totalDeps, - } = useMemo(() => { + const { dependsOnItems, blocksItems, totalDeps } = useMemo(() => { const dependsOnArr: { edgeId: string; otherId: string }[] = []; const blocksArr: { edgeId: string; otherId: string }[] = []; for (const e of edges) { - if (e.edgeType !== 'depends_on') continue; + if (e.edgeType !== "depends_on") continue; if (e.sourceTaskId === taskId) { dependsOnArr.push({ edgeId: e.id, otherId: e.targetTaskId }); } else if (e.targetTaskId === taskId) { @@ -166,88 +169,113 @@ export function PropRail({ // in `TaskPatch` is present on both shapes, so the same patch object // is safe to spread onto either. const queryClient = useQueryClient(); - const applyOptimisticPatch = useCallback((patch: TaskPatch) => { - const taskKey = taskKeys.detail(projectId, taskId); - const graphKey = projectKeys.graph(projectId); - - void queryClient.cancelQueries({ queryKey: taskKey }); - void queryClient.cancelQueries({ queryKey: graphKey }); + const applyOptimisticPatch = useCallback( + (patch: TaskPatch) => { + const taskKey = taskKeys.detail(projectId, taskId); + const graphKey = projectKeys.graph(projectId); + + void queryClient.cancelQueries({ queryKey: taskKey }); + void queryClient.cancelQueries({ queryKey: graphKey }); + + queryClient.setQueryData(taskKey, (prev) => + prev ? { ...prev, ...patch } : prev, + ); + queryClient.setQueryData(graphKey, (prev) => + prev + ? { + ...prev, + tasks: prev.tasks.map((t) => + t.id === taskId ? { ...t, ...patch } : t, + ), + } + : prev, + ); + }, + [projectId, taskId, queryClient], + ); - queryClient.setQueryData(taskKey, (prev) => - prev ? { ...prev, ...patch } : prev, - ); - queryClient.setQueryData(graphKey, (prev) => - prev - ? { - ...prev, - tasks: prev.tasks.map((t) => - t.id === taskId ? { ...t, ...patch } : t, - ), - } - : prev, - ); - }, [projectId, taskId, queryClient]); - - const handleRestoreStatus = useCallback(async (prev: TaskStatus) => { - applyOptimisticPatch({ status: prev }); - try { - await updateTask(taskId, { status: prev }); - } finally { - onGraphChange?.(); - } - }, [taskId, applyOptimisticPatch, onGraphChange]); + const handleRestoreStatus = useCallback( + async (prev: TaskStatus) => { + applyOptimisticPatch({ status: prev }); + try { + await updateTask(taskId, { status: prev }); + } finally { + onGraphChange?.(); + } + }, + [taskId, applyOptimisticPatch, onGraphChange], + ); - const { canUndo: canUndoStatus, push: pushStatusUndo, undo: undoStatus } = useUndo({ + const { + canUndo: canUndoStatus, + push: pushStatusUndo, + undo: undoStatus, + } = useUndo({ onUndo: handleRestoreStatus, resetOn: taskId, }); - const handleStatusChange = useCallback(async (next: TaskStatus) => { - if (next === status) return; - pushStatusUndo(status); - applyOptimisticPatch({ status: next }); - try { - await updateTask(taskId, { status: next }); - } finally { - onGraphChange?.(); - } - }, [taskId, status, pushStatusUndo, applyOptimisticPatch, onGraphChange]); - - const handleCategoryChange = useCallback(async (next: string | null) => { - applyOptimisticPatch({ category: next }); - try { - await updateTask(taskId, { category: next }); - } finally { - onGraphChange?.(); - } - }, [taskId, applyOptimisticPatch, onGraphChange]); - - const handleTagsChange = useCallback(async (next: string[]) => { - applyOptimisticPatch({ tags: next }); - try { - await updateTask(taskId, { tags: next }, true); - } finally { - onGraphChange?.(); - } - }, [taskId, applyOptimisticPatch, onGraphChange]); - - const handlePriorityChange = useCallback(async (next: Priority | null) => { - applyOptimisticPatch({ priority: next }); - try { - await updateTask(taskId, { priority: next }); - } finally { - onGraphChange?.(); - } - }, [taskId, applyOptimisticPatch, onGraphChange]); - - const handleEstimateChange = useCallback(async (next: Estimate | null) => { - applyOptimisticPatch({ estimate: next }); - try { - await updateTask(taskId, { estimate: next }); - } finally { - onGraphChange?.(); - } - }, [taskId, applyOptimisticPatch, onGraphChange]); + const handleStatusChange = useCallback( + async (next: TaskStatus) => { + if (next === status) return; + pushStatusUndo(status); + applyOptimisticPatch({ status: next }); + try { + await updateTask(taskId, { status: next }); + } finally { + onGraphChange?.(); + } + }, + [taskId, status, pushStatusUndo, applyOptimisticPatch, onGraphChange], + ); + + const handleCategoryChange = useCallback( + async (next: string | null) => { + applyOptimisticPatch({ category: next }); + try { + await updateTask(taskId, { category: next }); + } finally { + onGraphChange?.(); + } + }, + [taskId, applyOptimisticPatch, onGraphChange], + ); + + const handleTagsChange = useCallback( + async (next: string[]) => { + applyOptimisticPatch({ tags: next }); + try { + await updateTask(taskId, { tags: next }, true); + } finally { + onGraphChange?.(); + } + }, + [taskId, applyOptimisticPatch, onGraphChange], + ); + + const handlePriorityChange = useCallback( + async (next: Priority | null) => { + applyOptimisticPatch({ priority: next }); + try { + await updateTask(taskId, { priority: next }); + } finally { + onGraphChange?.(); + } + }, + [taskId, applyOptimisticPatch, onGraphChange], + ); + + const handleEstimateChange = useCallback( + async (next: Estimate | null) => { + applyOptimisticPatch({ estimate: next }); + try { + await updateTask(taskId, { estimate: next }); + } finally { + onGraphChange?.(); + } + }, + [taskId, applyOptimisticPatch, onGraphChange], + ); // Optimistic assignee updates: rewrite both the task-detail cache // (drives PropRail's trigger + name resolution) and the slim graph @@ -278,112 +306,149 @@ export function PropRail({ const pendingAssigneeWritesRef = useRef(0); const latestAssigneeIntentRef = useRef(null); - const applyAssigneesOptimistically = useCallback((nextUserIds: string[]) => { - const taskKey = taskKeys.detail(projectId, taskId); - const graphKey = projectKeys.graph(projectId); - - // Resolve the new assignee projection from the team-members cache. If - // the cache is cold (graph-rooted entry, picker opened before the - // query resolved), `getQueryData` returns undefined; the safe fall - // back is to keep `prev.assignees` for unresolved ids so a cold cache - // cannot drop an assignee. - const cachedMembers = - queryClient.getQueryData(teamKeys.members(organizationId)); - const memberById = new Map((cachedMembers ?? []).map((m) => [m.userId, m])); - - queryClient.setQueryData(taskKey, (prev) => { - if (!prev) return prev; - const prevById = new Map(prev.assignees.map((a) => [a.userId, a])); - const nextAssignees: AssigneeRef[] = nextUserIds - .map((id) => { - const fromCache = memberById.get(id); - if (fromCache) { - return { userId: fromCache.userId, name: fromCache.name, email: fromCache.email }; - } - return prevById.get(id); - }) - .filter((a): a is AssigneeRef => a !== undefined) - .sort((a, b) => a.name.localeCompare(b.name)); - return { ...prev, assignees: nextAssignees }; - }); - queryClient.setQueryData(graphKey, (prev) => - prev - ? { - ...prev, - tasks: prev.tasks.map((t) => - t.id === taskId - ? { ...t, assigneeUserIds: nextUserIds, assigneeCount: nextUserIds.length } - : t, - ), - } - : prev, - ); - }, [projectId, taskId, organizationId, queryClient]); - - const handleAssigneesChange = useCallback((nextUserIds: string[]) => { - const taskKey = taskKeys.detail(projectId, taskId); - const graphKey = projectKeys.graph(projectId); - - // Track latest user intent — re-applied after each mutation lands so - // an SSE-driven refetch that completed mid-chain can't reveal an - // intermediate server state. - latestAssigneeIntentRef.current = nextUserIds; - - // Cancel any in-flight refetch — `RealtimeBridge` may have invalidated - // the query from a previous mutation's SSE event, and the resulting - // refetch is currently in flight. Without cancellation, that refetch - // can complete after our `setQueryData` below and overwrite it. - void queryClient.cancelQueries({ queryKey: taskKey }); - void queryClient.cancelQueries({ queryKey: graphKey }); - - applyAssigneesOptimistically(nextUserIds); + const applyAssigneesOptimistically = useCallback( + (nextUserIds: string[]) => { + const taskKey = taskKeys.detail(projectId, taskId); + const graphKey = projectKeys.graph(projectId); + + // Resolve the new assignee projection from the team-members cache. If + // the cache is cold (graph-rooted entry, picker opened before the + // query resolved), `getQueryData` returns undefined; the safe fall + // back is to keep `prev.assignees` for unresolved ids so a cold cache + // cannot drop an assignee. + const cachedMembers = queryClient.getQueryData( + teamKeys.members(organizationId), + ); + const memberById = new Map( + (cachedMembers ?? []).map((m) => [m.userId, m]), + ); + + queryClient.setQueryData(taskKey, (prev) => { + if (!prev) return prev; + const prevById = new Map(prev.assignees.map((a) => [a.userId, a])); + const nextAssignees: AssigneeRef[] = nextUserIds + .map((id) => { + const fromCache = memberById.get(id); + if (fromCache) { + return { + userId: fromCache.userId, + name: fromCache.name, + email: fromCache.email, + }; + } + return prevById.get(id); + }) + .filter((a): a is AssigneeRef => a !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)); + return { ...prev, assignees: nextAssignees }; + }); + queryClient.setQueryData(graphKey, (prev) => + prev + ? { + ...prev, + tasks: prev.tasks.map((t) => + t.id === taskId + ? { + ...t, + assigneeUserIds: nextUserIds, + assigneeCount: nextUserIds.length, + } + : t, + ), + } + : prev, + ); + }, + [projectId, taskId, organizationId, queryClient], + ); - pendingAssigneeWritesRef.current += 1; - const myTurn = assigneeMutationChainRef.current.then(async () => { - try { - // Skip when newer clicks have queued after this step — - // `overwriteArrays=true` makes the later write a complete superset - // so this intermediate call is redundant, and dropping it reduces - // the SSE storm that drives race #2 above. - if (pendingAssigneeWritesRef.current > 1) return; - await updateTask(taskId, { assigneeIds: nextUserIds }, true); - } finally { - pendingAssigneeWritesRef.current -= 1; - if (pendingAssigneeWritesRef.current === 0) { - // Final mutation drained — let the broker-triggered refetch sync - // the server's settled view back in. - latestAssigneeIntentRef.current = null; - onGraphChange?.(); - } else { - // More clicks queued: an SSE event from this step's response is - // about to (or already has) triggered an invalidating refetch - // that would land an intermediate snapshot on the cache. Cancel - // it and re-apply the latest intent so the trigger stays at the - // user's latest selection. - void queryClient.cancelQueries({ queryKey: taskKey }); - void queryClient.cancelQueries({ queryKey: graphKey }); - const latest = latestAssigneeIntentRef.current; - if (latest !== null) applyAssigneesOptimistically(latest); + const handleAssigneesChange = useCallback( + (nextUserIds: string[]) => { + const taskKey = taskKeys.detail(projectId, taskId); + const graphKey = projectKeys.graph(projectId); + + // Track latest user intent — re-applied after each mutation lands so + // an SSE-driven refetch that completed mid-chain can't reveal an + // intermediate server state. + latestAssigneeIntentRef.current = nextUserIds; + + // Cancel any in-flight refetch — `RealtimeBridge` may have invalidated + // the query from a previous mutation's SSE event, and the resulting + // refetch is currently in flight. Without cancellation, that refetch + // can complete after our `setQueryData` below and overwrite it. + void queryClient.cancelQueries({ queryKey: taskKey }); + void queryClient.cancelQueries({ queryKey: graphKey }); + + applyAssigneesOptimistically(nextUserIds); + + pendingAssigneeWritesRef.current += 1; + const myTurn = assigneeMutationChainRef.current.then(async () => { + try { + // Skip when newer clicks have queued after this step — + // `overwriteArrays=true` makes the later write a complete superset + // so this intermediate call is redundant, and dropping it reduces + // the SSE storm that drives race #2 above. + if (pendingAssigneeWritesRef.current > 1) return; + await updateTask(taskId, { assigneeIds: nextUserIds }, true); + } finally { + pendingAssigneeWritesRef.current -= 1; + if (pendingAssigneeWritesRef.current === 0) { + // Final mutation drained — let the broker-triggered refetch sync + // the server's settled view back in. + latestAssigneeIntentRef.current = null; + onGraphChange?.(); + } else { + // More clicks queued: an SSE event from this step's response is + // about to (or already has) triggered an invalidating refetch + // that would land an intermediate snapshot on the cache. Cancel + // it and re-apply the latest intent so the trigger stays at the + // user's latest selection. + void queryClient.cancelQueries({ queryKey: taskKey }); + void queryClient.cancelQueries({ queryKey: graphKey }); + const latest = latestAssigneeIntentRef.current; + if (latest !== null) applyAssigneesOptimistically(latest); + } } - } - }); - assigneeMutationChainRef.current = myTurn.catch(() => {}); - return myTurn; - }, [projectId, taskId, queryClient, onGraphChange, applyAssigneesOptimistically]); + }); + assigneeMutationChainRef.current = myTurn.catch(() => {}); + return myTurn; + }, + [ + projectId, + taskId, + queryClient, + onGraphChange, + applyAssigneesOptimistically, + ], + ); return (
); } diff --git a/components/workspace/project-settings/DescriptionSection.tsx b/components/workspace/project-settings/DescriptionSection.tsx index 1cba92e..e1a8cd1 100644 --- a/components/workspace/project-settings/DescriptionSection.tsx +++ b/components/workspace/project-settings/DescriptionSection.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useCallback, useState } from 'react'; -import { updateProjectSettings } from '@/lib/actions/project'; -import { AutoGrowTextarea } from '@/components/shared/AutoGrowTextarea'; -import { Markdown } from '@/components/shared/Markdown'; +import { useCallback, useState } from "react"; +import { updateProjectSettings } from "@/lib/actions/project"; +import { AutoGrowTextarea } from "@/components/shared/AutoGrowTextarea"; +import { Markdown } from "@/components/shared/Markdown"; interface DescriptionSectionProps { projectId: string; @@ -12,17 +12,22 @@ interface DescriptionSectionProps { } const SECTION_LABEL_CLASS = - 'font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted'; + "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; /** * Click-to-edit textarea (3 rows) that persists on blur. * @param props - Section props. * @returns Description row. */ -export function DescriptionSection({ projectId, initialDescription, onUpdated }: DescriptionSectionProps) { +export function DescriptionSection({ + projectId, + initialDescription, + onUpdated, +}: DescriptionSectionProps) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(initialDescription); - const [syncedInitialDescription, setSyncedInitialDescription] = useState(initialDescription); + const [syncedInitialDescription, setSyncedInitialDescription] = + useState(initialDescription); const [serverError, setServerError] = useState(null); if (initialDescription !== syncedInitialDescription && !editing) { @@ -38,10 +43,18 @@ export function DescriptionSection({ projectId, initialDescription, onUpdated }: setEditing(false); const trimmed = value.trim(); if (trimmed !== value) setValue(trimmed); - if (trimmed === initialDescription) { setServerError(null); return; } + if (trimmed === initialDescription) { + setServerError(null); + return; + } setServerError(null); - const result = await updateProjectSettings(projectId, { description: trimmed }); - if (!result.ok) { setServerError(result.message); return; } + const result = await updateProjectSettings(projectId, { + description: trimmed, + }); + if (!result.ok) { + setServerError(result.message); + return; + } onUpdated?.(); }, [value, initialDescription, projectId, onUpdated]); @@ -55,7 +68,11 @@ export function DescriptionSection({ projectId, initialDescription, onUpdated }: onChange={(e) => setValue(e.target.value)} onBlur={commit} onKeyDown={(e) => { - if (e.key === 'Escape') { setValue(initialDescription); setServerError(null); setEditing(false); } + if (e.key === "Escape") { + setValue(initialDescription); + setServerError(null); + setEditing(false); + } }} autoFocus className="w-full resize-none rounded-lg border border-border-strong bg-base px-3 py-2 text-sm text-text-secondary outline-none transition-colors focus:border-accent" @@ -66,7 +83,10 @@ export function DescriptionSection({ projectId, initialDescription, onUpdated }: role="button" tabIndex={0} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setEditing(true); } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setEditing(true); + } }} className="cursor-pointer rounded-lg border border-transparent px-3 py-2 text-sm text-text-secondary transition-colors hover:border-border hover:bg-surface-hover/40" > diff --git a/components/workspace/project-settings/IdentifierSection.tsx b/components/workspace/project-settings/IdentifierSection.tsx index fa089a3..2a36a73 100644 --- a/components/workspace/project-settings/IdentifierSection.tsx +++ b/components/workspace/project-settings/IdentifierSection.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useEffect, useReducer, useRef } from 'react'; -import { motion } from 'motion/react'; -import { parseIdentifier } from '@/lib/graph/identifier'; -import { updateProjectSettings } from '@/lib/actions/project'; +import { useEffect, useReducer, useRef } from "react"; +import { motion } from "motion/react"; +import { parseIdentifier } from "@/lib/graph/identifier"; +import { updateProjectSettings } from "@/lib/actions/project"; interface IdentifierSectionProps { projectId: string; @@ -15,25 +15,30 @@ interface IdentifierSectionProps { /** Internal state for the identifier edit flow. */ type IdentifierState = - | { kind: 'closed'; initial: string } - | { kind: 'editing'; draft: string; initial: string; validationError?: string } - | { kind: 'confirming'; draft: string; initial: string } - | { kind: 'saving'; draft: string; initial: string } - | { kind: 'error'; draft: string; initial: string; serverError: string }; + | { kind: "closed"; initial: string } + | { + kind: "editing"; + draft: string; + initial: string; + validationError?: string; + } + | { kind: "confirming"; draft: string; initial: string } + | { kind: "saving"; draft: string; initial: string } + | { kind: "error"; draft: string; initial: string; serverError: string }; /** Transitions applied by {@link identifierReducer}. */ type IdentifierAction = - | { type: 'sync_initial'; initial: string } - | { type: 'start_edit' } - | { type: 'edit'; draft: string; validationError?: string } - | { type: 'request_confirm' } - | { type: 'cancel_confirm' } - | { type: 'submit' } - | { type: 'submit_failure'; serverError: string } - | { type: 'cancel' }; + | { type: "sync_initial"; initial: string } + | { type: "start_edit" } + | { type: "edit"; draft: string; validationError?: string } + | { type: "request_confirm" } + | { type: "cancel_confirm" } + | { type: "submit" } + | { type: "submit_failure"; serverError: string } + | { type: "cancel" }; const SECTION_LABEL_CLASS = - 'font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted'; + "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; /** * Reduce a single action on the identifier edit state machine. @@ -41,31 +46,45 @@ const SECTION_LABEL_CLASS = * @param action - Dispatched transition. * @returns Next state; unchanged if the transition is not valid for `state`. */ -function identifierReducer(state: IdentifierState, action: IdentifierAction): IdentifierState { +function identifierReducer( + state: IdentifierState, + action: IdentifierAction, +): IdentifierState { switch (action.type) { - case 'sync_initial': { - if (state.kind === 'closed') return { kind: 'closed', initial: action.initial }; + case "sync_initial": { + if (state.kind === "closed") + return { kind: "closed", initial: action.initial }; return state; } - case 'start_edit': - return { kind: 'editing', draft: state.initial, initial: state.initial }; - case 'edit': - if (state.kind !== 'editing') return state; - return { kind: 'editing', draft: action.draft, initial: state.initial, validationError: action.validationError }; - case 'request_confirm': - if (state.kind !== 'editing' || state.validationError) return state; - return { kind: 'confirming', draft: state.draft, initial: state.initial }; - case 'cancel_confirm': - if (state.kind !== 'confirming' && state.kind !== 'error') return state; - return { kind: 'editing', draft: state.draft, initial: state.initial }; - case 'submit': - if (state.kind !== 'confirming') return state; - return { kind: 'saving', draft: state.draft, initial: state.initial }; - case 'submit_failure': - if (state.kind !== 'saving') return state; - return { kind: 'error', draft: state.draft, initial: state.initial, serverError: action.serverError }; - case 'cancel': - return { kind: 'closed', initial: state.initial }; + case "start_edit": + return { kind: "editing", draft: state.initial, initial: state.initial }; + case "edit": + if (state.kind !== "editing") return state; + return { + kind: "editing", + draft: action.draft, + initial: state.initial, + validationError: action.validationError, + }; + case "request_confirm": + if (state.kind !== "editing" || state.validationError) return state; + return { kind: "confirming", draft: state.draft, initial: state.initial }; + case "cancel_confirm": + if (state.kind !== "confirming" && state.kind !== "error") return state; + return { kind: "editing", draft: state.draft, initial: state.initial }; + case "submit": + if (state.kind !== "confirming") return state; + return { kind: "saving", draft: state.draft, initial: state.initial }; + case "submit_failure": + if (state.kind !== "saving") return state; + return { + kind: "error", + draft: state.draft, + initial: state.initial, + serverError: action.serverError, + }; + case "cancel": + return { kind: "closed", initial: state.initial }; } } @@ -79,12 +98,21 @@ function identifierReducer(state: IdentifierState, action: IdentifierAction): Id * @param props - Section props. * @returns Identifier row with rename flow. */ -export function IdentifierSection({ projectId, identifier, taskCount, canRename, onUpdated }: IdentifierSectionProps) { - const [state, dispatch] = useReducer(identifierReducer, { kind: 'closed', initial: identifier }); +export function IdentifierSection({ + projectId, + identifier, + taskCount, + canRename, + onUpdated, +}: IdentifierSectionProps) { + const [state, dispatch] = useReducer(identifierReducer, { + kind: "closed", + initial: identifier, + }); const submittingRef = useRef(false); useEffect(() => { - dispatch({ type: 'sync_initial', initial: identifier }); + dispatch({ type: "sync_initial", initial: identifier }); }, [identifier]); if (!canRename) { @@ -93,7 +121,9 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename,
{identifier} - renaming requires admin + + renaming requires admin +
); @@ -104,10 +134,13 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, * @param raw - Raw input from the text field. */ const handleDraftChange = (raw: string): void => { - const next = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12); + const next = raw + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 12); const parsed = parseIdentifier(next); const validationError = parsed.ok ? undefined : parsed.error; - dispatch({ type: 'edit', draft: next, validationError }); + dispatch({ type: "edit", draft: next, validationError }); }; /** @@ -118,42 +151,51 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, const commitRename = async (draft: string): Promise => { if (submittingRef.current) return; submittingRef.current = true; - dispatch({ type: 'submit' }); + dispatch({ type: "submit" }); try { - const result = await updateProjectSettings(projectId, { identifier: draft }); + const result = await updateProjectSettings(projectId, { + identifier: draft, + }); if (result.ok) { - dispatch({ type: 'cancel' }); + dispatch({ type: "cancel" }); onUpdated?.(); return; } - dispatch({ type: 'submit_failure', serverError: result.message }); + dispatch({ type: "submit_failure", serverError: result.message }); } finally { submittingRef.current = false; } }; - if (state.kind === 'closed') { + if (state.kind === "closed") { return (
); } const draft = state.draft; - const validationError = state.kind === 'editing' ? state.validationError : undefined; - const canSave = !validationError && draft !== state.initial && draft.length >= 2; - const isSaving = state.kind === 'saving'; - const showConfirmPanel = state.kind === 'confirming' || state.kind === 'saving' || state.kind === 'error'; - const serverError = state.kind === 'error' ? state.serverError : null; + const validationError = + state.kind === "editing" ? state.validationError : undefined; + const canSave = + !validationError && draft !== state.initial && draft.length >= 2; + const isSaving = state.kind === "saving"; + const showConfirmPanel = + state.kind === "confirming" || + state.kind === "saving" || + state.kind === "error"; + const serverError = state.kind === "error" ? state.serverError : null; return (
@@ -164,7 +206,7 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, value={draft} onChange={(e) => handleDraftChange(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Escape') dispatch({ type: 'cancel' }); + if (e.key === "Escape") dispatch({ type: "cancel" }); }} autoFocus disabled={showConfirmPanel} @@ -175,7 +217,10 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, )} {!validationError && (

- Preview: {draft || state.initial}-1 + Preview:{" "} + + {draft || state.initial}-1 +

)} @@ -185,7 +230,7 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, whileHover={!canSave ? undefined : { scale: 1.02 }} whileTap={!canSave ? undefined : { scale: 0.98 }} type="button" - onClick={() => dispatch({ type: 'request_confirm' })} + onClick={() => dispatch({ type: "request_confirm" })} disabled={!canSave} className="cursor-pointer rounded-md border border-border-strong bg-transparent px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider text-text-primary shadow-[var(--shadow-button)] transition-opacity hover:opacity-60 disabled:cursor-not-allowed disabled:opacity-40" > @@ -195,7 +240,7 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} type="button" - onClick={() => dispatch({ type: 'cancel' })} + onClick={() => dispatch({ type: "cancel" })} className="cursor-pointer rounded-md bg-transparent px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted transition-colors hover:text-text-primary" > Cancel @@ -207,9 +252,10 @@ export function IdentifierSection({ projectId, identifier, taskCount, canRename, Rename {state.initial} → {draft}?

- All {taskCount} task IDs will change to{' '} - {draft}-N. External references - (GitHub PRs, docs, commit messages, links) to the old prefix will no longer resolve. + All {taskCount} task IDs will change to{" "} + {draft}-N. + External references (GitHub PRs, docs, commit messages, links) to + the old prefix will no longer resolve.

- {isSaving ? 'Renaming…' : 'Rename'} + {isSaving ? "Renaming…" : "Rename"} dispatch({ type: 'cancel_confirm' })} + onClick={() => dispatch({ type: "cancel_confirm" })} disabled={isSaving} className="cursor-pointer rounded-md bg-transparent px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted transition-colors hover:text-text-primary disabled:opacity-50" > diff --git a/components/workspace/project-settings/ProjectSettingsModal.tsx b/components/workspace/project-settings/ProjectSettingsModal.tsx index 70b0621..ac40a43 100644 --- a/components/workspace/project-settings/ProjectSettingsModal.tsx +++ b/components/workspace/project-settings/ProjectSettingsModal.tsx @@ -1,13 +1,13 @@ -'use client'; +"use client"; -import { Modal } from '@/components/shared/Modal'; -import { TeamSection } from './TeamSection'; -import { StatusSection } from './StatusSection'; -import { TitleSection } from './TitleSection'; -import { DescriptionSection } from './DescriptionSection'; -import { IdentifierSection } from './IdentifierSection'; -import { CategoriesSection } from './CategoriesSection'; -import type { ProjectStatus } from '@/lib/types'; +import { Modal } from "@/components/shared/Modal"; +import { TeamSection } from "./TeamSection"; +import { StatusSection } from "./StatusSection"; +import { TitleSection } from "./TitleSection"; +import { DescriptionSection } from "./DescriptionSection"; +import { IdentifierSection } from "./IdentifierSection"; +import { CategoriesSection } from "./CategoriesSection"; +import type { ProjectStatus } from "@/lib/types"; interface ProjectSettingsModalProps { /** @param open - Whether the modal is visible. */ @@ -17,7 +17,13 @@ interface ProjectSettingsModalProps { /** @param projectId - UUID of the project being edited. */ projectId: string; /** @param project - Current project fields reflected by the form. */ - project: { title: string; description: string; identifier: string; status: ProjectStatus; categories: string[] }; + project: { + title: string; + description: string; + identifier: string; + status: ProjectStatus; + categories: string[]; + }; /** @param team - Owning team. Read-only — project ownership is fixed at creation. */ team: { id: string; name: string }; /** @param taskCount - Number of tasks affected by an identifier rename. */ diff --git a/components/workspace/project-settings/StatusSection.tsx b/components/workspace/project-settings/StatusSection.tsx index 3bca942..be7c9ed 100644 --- a/components/workspace/project-settings/StatusSection.tsx +++ b/components/workspace/project-settings/StatusSection.tsx @@ -1,10 +1,13 @@ -'use client'; +"use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { motion } from 'motion/react'; -import { useRouter } from 'next/navigation'; -import { updateProjectStatus, type WebAllowedStatus } from '@/lib/actions/project'; -import type { ProjectStatus } from '@/lib/types'; +import { useCallback, useEffect, useRef, useState } from "react"; +import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { + updateProjectStatus, + type WebAllowedStatus, +} from "@/lib/actions/project"; +import type { ProjectStatus } from "@/lib/types"; interface StatusSectionProps { /** @param projectId - UUID of the project. */ @@ -15,13 +18,18 @@ interface StatusSectionProps { onUpdated?: () => void; } -const STATUS_OPTIONS: { id: WebAllowedStatus; label: string; dot: string; text: string }[] = [ - { id: 'active', label: 'Active', dot: 'bg-done', text: 'text-done' }, - { id: 'archived', label: 'Archived', dot: 'bg-draft', text: 'text-draft' }, +const STATUS_OPTIONS: { + id: WebAllowedStatus; + label: string; + dot: string; + text: string; +}[] = [ + { id: "active", label: "Active", dot: "bg-done", text: "text-done" }, + { id: "archived", label: "Archived", dot: "bg-draft", text: "text-draft" }, ]; const SECTION_LABEL_CLASS = - 'font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted'; + "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; /** * Active ↔ archived toggle. Hidden when the project is in a CLI-only phase @@ -30,7 +38,11 @@ const SECTION_LABEL_CLASS = * @param props - Section props. * @returns Status row with two-pill toggle, or null when status is CLI-managed. */ -export function StatusSection({ projectId, status, onUpdated }: StatusSectionProps) { +export function StatusSection({ + projectId, + status, + onUpdated, +}: StatusSectionProps) { const router = useRouter(); const [pending, setPending] = useState(null); const [error, setError] = useState(null); @@ -40,23 +52,31 @@ export function StatusSection({ projectId, status, onUpdated }: StatusSectionPro statusRef.current = status; }, [status]); - const handleStatusChange = useCallback(async (next: WebAllowedStatus): Promise => { - if (next === statusRef.current) return; - setPending(next); - setError(null); - try { - const result = await updateProjectStatus(projectId, next); - if (!result.ok) { setError(result.message); return; } - onUpdated?.(); - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update status'); - } finally { - setPending(null); - } - }, [projectId, onUpdated, router]); + const handleStatusChange = useCallback( + async (next: WebAllowedStatus): Promise => { + if (next === statusRef.current) return; + setPending(next); + setError(null); + try { + const result = await updateProjectStatus(projectId, next); + if (!result.ok) { + setError(result.message); + return; + } + onUpdated?.(); + router.refresh(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to update status", + ); + } finally { + setPending(null); + } + }, + [projectId, onUpdated, router], + ); - if (status !== 'active' && status !== 'archived') return null; + if (status !== "active" && status !== "archived") return null; const isBusy = pending !== null; @@ -78,21 +98,21 @@ export function StatusSection({ projectId, status, onUpdated }: StatusSectionPro className={`relative rounded-md px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider transition-all ${ isCurrent ? `${opt.text} bg-surface-raised ring-1 ring-current/20` - : 'text-text-muted hover:bg-surface-hover hover:text-text-secondary' - } ${isBusy ? 'cursor-not-allowed opacity-50' : isCurrent ? 'cursor-default' : 'cursor-pointer'} ${isPending ? 'opacity-60' : ''}`} + : "text-text-muted hover:bg-surface-hover hover:text-text-secondary" + } ${isBusy ? "cursor-not-allowed opacity-50" : isCurrent ? "cursor-default" : "cursor-pointer"} ${isPending ? "opacity-60" : ""}`} title={`Set status to ${opt.label}`} > - + {opt.label} ); })}
- {error && ( -

{error}

- )} + {error &&

{error}

}
); } diff --git a/components/workspace/project-settings/TeamSection.tsx b/components/workspace/project-settings/TeamSection.tsx index 66ad0af..1b90693 100644 --- a/components/workspace/project-settings/TeamSection.tsx +++ b/components/workspace/project-settings/TeamSection.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { TeamChip } from '@/components/shared/TeamChip'; +import { TeamChip } from "@/components/shared/TeamChip"; interface TeamSectionProps { /** @param team - Team that owns this project. Read-only display. */ @@ -8,7 +8,7 @@ interface TeamSectionProps { } const SECTION_LABEL_CLASS = - 'font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted'; + "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; /** * Read-only owning-team indicator for the project settings modal. Mirrors diff --git a/components/workspace/project-settings/TitleSection.tsx b/components/workspace/project-settings/TitleSection.tsx index ff3b0fb..079d081 100644 --- a/components/workspace/project-settings/TitleSection.tsx +++ b/components/workspace/project-settings/TitleSection.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useCallback, useState } from 'react'; -import { updateProjectSettings } from '@/lib/actions/project'; +import { useCallback, useState } from "react"; +import { updateProjectSettings } from "@/lib/actions/project"; interface TitleSectionProps { projectId: string; @@ -10,14 +10,18 @@ interface TitleSectionProps { } const SECTION_LABEL_CLASS = - 'font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted'; + "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; /** * Click-to-edit title input that persists on blur. * @param props - Section props. * @returns Title row. */ -export function TitleSection({ projectId, initialTitle, onUpdated }: TitleSectionProps) { +export function TitleSection({ + projectId, + initialTitle, + onUpdated, +}: TitleSectionProps) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(initialTitle); const [syncedInitialTitle, setSyncedInitialTitle] = useState(initialTitle); @@ -35,11 +39,21 @@ export function TitleSection({ projectId, initialTitle, onUpdated }: TitleSectio const commit = useCallback(async () => { setEditing(false); const trimmed = value.trim(); - if (!trimmed) { setValue(initialTitle); setServerError(null); return; } - if (trimmed === initialTitle) { setServerError(null); return; } + if (!trimmed) { + setValue(initialTitle); + setServerError(null); + return; + } + if (trimmed === initialTitle) { + setServerError(null); + return; + } setServerError(null); const result = await updateProjectSettings(projectId, { title: trimmed }); - if (!result.ok) { setServerError(result.message); return; } + if (!result.ok) { + setServerError(result.message); + return; + } onUpdated?.(); }, [value, initialTitle, projectId, onUpdated]); @@ -53,8 +67,15 @@ export function TitleSection({ projectId, initialTitle, onUpdated }: TitleSectio onChange={(e) => setValue(e.target.value)} onBlur={commit} onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } - if (e.key === 'Escape') { setValue(initialTitle); setServerError(null); setEditing(false); } + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + setValue(initialTitle); + setServerError(null); + setEditing(false); + } }} autoFocus className="w-full rounded-lg border border-border-strong bg-base px-3 py-2 text-sm text-text-primary outline-none transition-colors focus:border-accent" diff --git a/components/workspace/structure/DeleteConfirm.tsx b/components/workspace/structure/DeleteConfirm.tsx index b4de49c..057cca0 100644 --- a/components/workspace/structure/DeleteConfirm.tsx +++ b/components/workspace/structure/DeleteConfirm.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { IconX } from '@/components/shared/icons'; +import { IconX } from "@/components/shared/icons"; interface DeleteConfirmProps { /** @param onConfirm - Permanently delete the task. */ @@ -20,14 +20,20 @@ export function DeleteConfirm({ onConfirm, onCancel }: DeleteConfirmProps) { @@ -139,15 +147,23 @@ export function FilterPanel({ })} - {(categories.length > 0 || (categoryCounts.__uncategorized__ ?? 0) > 0) && ( + {(categories.length > 0 || + (categoryCounts.__uncategorized__ ?? 0) > 0) && ( {categories.map((cat) => { const count = categoryCounts[cat] ?? 0; const active = activeCategories.has(cat); return ( - @@ -156,11 +172,13 @@ export function FilterPanel({ {(categoryCounts.__uncategorized__ ?? 0) > 0 && ( @@ -196,7 +214,9 @@ export function FilterPanel({ > {p} - + {count} @@ -210,7 +230,9 @@ export function FilterPanel({ > Unprioritized - + {priorityCounts[UNPRIORITIZED_KEY] ?? 0} @@ -224,9 +246,16 @@ export function FilterPanel({ {tags.map(([tag, count]) => { const active = activeTags.has(tag); return ( - diff --git a/components/workspace/structure/StructureView.tsx b/components/workspace/structure/StructureView.tsx index 4fe40b2..00def69 100644 --- a/components/workspace/structure/StructureView.tsx +++ b/components/workspace/structure/StructureView.tsx @@ -1,56 +1,66 @@ -'use client'; - -import { motion, AnimatePresence } from 'motion/react'; -import { useRouter, useSearchParams, usePathname } from 'next/navigation'; -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { createTask, deleteTask } from '@/lib/graph/mutations'; -import { useUndo, UndoButton } from '@/hooks/useUndo'; -import { IconSearch, IconX, IconPlus } from '@/components/shared/icons'; -import { StatusGlyph, STATUS_META, type TaskStatus as GlyphStatus } from '@/components/shared/StatusGlyph'; -import type { TaskEdge } from '@/lib/db/schema'; -import type { TaskGraphSlim, TaskFull } from '@/lib/data/views'; -import type { TaskStatus } from '@/lib/types'; -import { taskKeys } from '@/lib/query/keys'; -import { fetchTaskBody } from '@/lib/query/queries'; -import { listTeamMembersAction } from '@/lib/actions/team-members'; -import type { MemberView } from '@/lib/actions/team-members-map'; -import { teamKeys } from '@/lib/query/keys'; +"use client"; + +import { motion, AnimatePresence } from "motion/react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { createTask, deleteTask } from "@/lib/graph/mutations"; +import { useUndo, UndoButton } from "@/hooks/useUndo"; +import { IconSearch, IconX, IconPlus } from "@/components/shared/icons"; +import { + StatusGlyph, + STATUS_META, + type TaskStatus as GlyphStatus, +} from "@/components/shared/StatusGlyph"; +import type { TaskEdge } from "@/lib/db/schema"; +import type { TaskGraphSlim, TaskFull } from "@/lib/data/views"; +import type { TaskStatus } from "@/lib/types"; +import { taskKeys } from "@/lib/query/keys"; +import { fetchTaskBody } from "@/lib/query/queries"; +import { listTeamMembersAction } from "@/lib/actions/team-members"; +import type { MemberView } from "@/lib/actions/team-members-map"; +import { teamKeys } from "@/lib/query/keys"; import { PRIORITY_DISPLAY_ORDER, PRIORITY_RANK, PRIORITY_RANK_UNSET, UNPRIORITIZED_KEY, -} from '@/lib/ui/priority'; -import { TaskRow } from './TaskRow'; -import { type TaskGroupKey } from './TaskGroup'; -import type { GroupKey, SortKey } from './FilterBar'; -import { FilterPanel } from './FilterPanel'; -import { formatRelative } from './relativeTime'; +} from "@/lib/ui/priority"; +import { TaskRow } from "./TaskRow"; +import { type TaskGroupKey } from "./TaskGroup"; +import type { GroupKey, SortKey } from "./FilterBar"; +import { FilterPanel } from "./FilterPanel"; +import { formatRelative } from "./relativeTime"; /** URL search-param keys persisting filter state. */ -const FILTER_PARAM_KEYS = { tags: 'tags', categories: 'cat', statuses: 'status', priorities: 'pri', search: 'q' } as const; +const FILTER_PARAM_KEYS = { + tags: "tags", + categories: "cat", + statuses: "status", + priorities: "pri", + search: "q", +} as const; /** Display order for status groups — most actionable at the top. */ const GROUP_ORDER: readonly TaskGroupKey[] = [ - 'in_progress', - 'in_review', - 'ready', - 'planned', - 'plannable', - 'draft', - 'done', - 'cancelled', + "in_progress", + "in_review", + "ready", + "planned", + "plannable", + "draft", + "done", + "cancelled", ]; type TaskWithRef = TaskGraphSlim; /** Discriminated union describing a group section's identity and label source. */ type GroupSection = - | { kind: 'status'; key: TaskGroupKey } - | { kind: 'category'; key: string; label: string } - | { kind: 'flat' }; + | { kind: "status"; key: TaskGroupKey } + | { kind: "category"; key: string; label: string } + | { kind: "flat" }; interface StructureViewProps { /** All project tasks, augmented with composed `taskRef`. */ @@ -91,8 +101,8 @@ interface StructureViewProps { * @returns Group key. */ function groupKeyFor(task: TaskWithRef): TaskGroupKey { - if (task.state === 'ready') return 'ready'; - if (task.state === 'plannable') return 'plannable'; + if (task.state === "ready") return "ready"; + if (task.state === "plannable") return "plannable"; return task.status; } @@ -104,7 +114,12 @@ function groupKeyFor(task: TaskWithRef): TaskGroupKey { */ function parseSet(value: string | null): Set { if (!value) return new Set(); - return new Set(value.split(',').map((v) => v.trim()).filter(Boolean)); + return new Set( + value + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + ); } /** @@ -117,12 +132,18 @@ function parseSet(value: string | null): Set { */ function serializeFilters( current: URLSearchParams, - next: { tags: Set; categories: Set; statuses: Set; priorities: Set; search: string }, + next: { + tags: Set; + categories: Set; + statuses: Set; + priorities: Set; + search: string; + }, ): string { const out = new URLSearchParams(current); const apply = (key: string, set: Set) => { if (set.size === 0) out.delete(key); - else out.set(key, [...set].join(',')); + else out.set(key, [...set].join(",")); }; apply(FILTER_PARAM_KEYS.tags, next.tags); apply(FILTER_PARAM_KEYS.categories, next.categories); @@ -131,7 +152,7 @@ function serializeFilters( if (next.search.trim()) out.set(FILTER_PARAM_KEYS.search, next.search.trim()); else out.delete(FILTER_PARAM_KEYS.search); const qs = out.toString(); - return qs ? `?${qs}` : ''; + return qs ? `?${qs}` : ""; } interface DepsMap { @@ -151,9 +172,12 @@ function buildDepsMap(edges: TaskEdge[]): DepsMap { const upstream = new Map(); const downstream = new Map(); for (const edge of edges) { - if (edge.edgeType !== 'depends_on') continue; + if (edge.edgeType !== "depends_on") continue; upstream.set(edge.sourceTaskId, (upstream.get(edge.sourceTaskId) ?? 0) + 1); - downstream.set(edge.targetTaskId, (downstream.get(edge.targetTaskId) ?? 0) + 1); + downstream.set( + edge.targetTaskId, + (downstream.get(edge.targetTaskId) ?? 0) + 1, + ); } return { upstream, downstream }; } @@ -175,15 +199,17 @@ interface DeletedTask { */ function sortTasks(items: TaskWithRef[], key: SortKey): TaskWithRef[] { const copy = [...items]; - if (key === 'updated') { + if (key === "updated") { copy.sort((a, b) => { const at = a.updatedAt ? Date.parse(String(a.updatedAt)) : 0; const bt = b.updatedAt ? Date.parse(String(b.updatedAt)) : 0; return bt - at; }); - } else if (key === 'identifier') { - copy.sort((a, b) => a.taskRef.localeCompare(b.taskRef, undefined, { numeric: true })); - } else if (key === 'priority') { + } else if (key === "identifier") { + copy.sort((a, b) => + a.taskRef.localeCompare(b.taskRef, undefined, { numeric: true }), + ); + } else if (key === "priority") { // Unset priorities sort below the lowest assigned value so the user // sees a meaningful gradient first; ties fall back to `order` to keep // adjacent rows stable. @@ -224,8 +250,12 @@ export function StructureView({ const pathname = usePathname(); const searchParams = useSearchParams(); - const [activeStatuses, setActiveStatuses] = useState>(() => parseSet(searchParams.get(FILTER_PARAM_KEYS.statuses))); - const [activeCategories, setActiveCategories] = useState>(() => parseSet(searchParams.get(FILTER_PARAM_KEYS.categories))); + const [activeStatuses, setActiveStatuses] = useState>(() => + parseSet(searchParams.get(FILTER_PARAM_KEYS.statuses)), + ); + const [activeCategories, setActiveCategories] = useState>(() => + parseSet(searchParams.get(FILTER_PARAM_KEYS.categories)), + ); const [activeTags, setActiveTags] = useState>(() => parseSet(searchParams.get(FILTER_PARAM_KEYS.tags)), ); @@ -234,22 +264,43 @@ export function StructureView({ // sentinel so a stale bookmark with an unknown token cannot empty the // list. const parsed = parseSet(searchParams.get(FILTER_PARAM_KEYS.priorities)); - const allowed = new Set([UNPRIORITIZED_KEY, ...PRIORITY_DISPLAY_ORDER]); + const allowed = new Set([ + UNPRIORITIZED_KEY, + ...PRIORITY_DISPLAY_ORDER, + ]); for (const p of [...parsed]) if (!allowed.has(p)) parsed.delete(p); return parsed; }); - const [search, setSearch] = useState(() => searchParams.get(FILTER_PARAM_KEYS.search) ?? ''); + const [search, setSearch] = useState( + () => searchParams.get(FILTER_PARAM_KEYS.search) ?? "", + ); const [addingToGroup, setAddingToGroup] = useState(null); - const [addTitle, setAddTitle] = useState(''); + const [addTitle, setAddTitle] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); - const pendingDeleteBodyRef = useRef>>(new Map()); - const filtersRef = useRef({ tags: activeTags, categories: activeCategories, statuses: activeStatuses, priorities: activePriorities, search }); + const pendingDeleteBodyRef = useRef>>( + new Map(), + ); + const filtersRef = useRef({ + tags: activeTags, + categories: activeCategories, + statuses: activeStatuses, + priorities: activePriorities, + search, + }); - filtersRef.current = { tags: activeTags, categories: activeCategories, statuses: activeStatuses, priorities: activePriorities, search }; + filtersRef.current = { + tags: activeTags, + categories: activeCategories, + statuses: activeStatuses, + priorities: activePriorities, + search, + }; useEffect(() => { const qs = serializeFilters(searchParams, filtersRef.current); - const currentQs = searchParams.toString() ? `?${searchParams.toString()}` : ''; + const currentQs = searchParams.toString() + ? `?${searchParams.toString()}` + : ""; // Skip the no-op `router.replace` on mount when filter state already // mirrors the URL (initial state is parsed FROM the URL via // `searchParams.get(...)` above). Each `router.replace` triggers an @@ -273,14 +324,11 @@ export function StructureView({ }, staleTime: 5 * 60_000, }); - const memberLookup = useMemo>( - () => { - const map = new Map(); - for (const m of teamMembers ?? []) map.set(m.userId, m); - return map; - }, - [teamMembers], - ); + const memberLookup = useMemo>(() => { + const map = new Map(); + for (const m of teamMembers ?? []) map.set(m.userId, m); + return map; + }, [teamMembers]); const depsMap = useMemo(() => buildDepsMap(edges), [edges]); @@ -301,7 +349,9 @@ export function StructureView({ const list = (task.tags as string[] | null) ?? []; for (const tag of list) counts.set(tag, (counts.get(tag) ?? 0) + 1); } - return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])) as ReadonlyArray; + return [...counts.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ) as ReadonlyArray; }, [tasks]); const statusCounts = useMemo(() => { @@ -337,10 +387,11 @@ export function StructureView({ return tasks.filter((t) => { const groupKey = groupKeyFor(t); - if (activeStatuses.size > 0 && !activeStatuses.has(groupKey)) return false; + if (activeStatuses.size > 0 && !activeStatuses.has(groupKey)) + return false; if (activeCategories.size > 0) { - if (!t.category && !activeCategories.has('Uncategorized')) return false; + if (!t.category && !activeCategories.has("Uncategorized")) return false; if (t.category && !activeCategories.has(t.category)) return false; } @@ -361,29 +412,45 @@ export function StructureView({ return true; }); - }, [tasks, activeStatuses, activeCategories, activeTags, activePriorities, search]); - - const groupedVisible = useMemo>(() => { - if (group === 'none') { - return [[ { kind: 'flat' }, sortTasks(visibleTasks, sort) ]]; + }, [ + tasks, + activeStatuses, + activeCategories, + activeTags, + activePriorities, + search, + ]); + + const groupedVisible = useMemo< + ReadonlyArray + >(() => { + if (group === "none") { + return [[{ kind: "flat" }, sortTasks(visibleTasks, sort)]]; } - if (group === 'category') { + if (group === "category") { const map = new Map(); for (const task of visibleTasks) { - const key = task.category ?? '__uncategorized__'; + const key = task.category ?? "__uncategorized__"; const list = map.get(key) ?? []; list.push(task); map.set(key, list); } const labels = [...map.keys()].sort((a, b) => { - if (a === '__uncategorized__') return 1; - if (b === '__uncategorized__') return -1; + if (a === "__uncategorized__") return 1; + if (b === "__uncategorized__") return -1; return a.localeCompare(b); }); - return labels.map((key) => [ - { kind: 'category' as const, key, label: key === '__uncategorized__' ? 'Uncategorized' : key }, - sortTasks(map.get(key) ?? [], sort), - ] as const); + return labels.map( + (key) => + [ + { + kind: "category" as const, + key, + label: key === "__uncategorized__" ? "Uncategorized" : key, + }, + sortTasks(map.get(key) ?? [], sort), + ] as const, + ); } const map = new Map(); for (const task of visibleTasks) { @@ -392,18 +459,20 @@ export function StructureView({ list.push(task); map.set(key, list); } - return GROUP_ORDER - .filter((key) => (map.get(key)?.length ?? 0) > 0) - .map((key) => [ - { kind: 'status' as const, key }, - sortTasks(map.get(key) ?? [], sort), - ] as const); + return GROUP_ORDER.filter((key) => (map.get(key)?.length ?? 0) > 0).map( + (key) => + [ + { kind: "status" as const, key }, + sortTasks(map.get(key) ?? [], sort), + ] as const, + ); }, [visibleTasks, sort, group]); const toggleStatus = useCallback((id: string) => { setActiveStatuses((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }, []); @@ -411,7 +480,8 @@ export function StructureView({ const toggleCategory = useCallback((id: string) => { setActiveCategories((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }, []); @@ -419,7 +489,8 @@ export function StructureView({ const toggleTag = useCallback((id: string) => { setActiveTags((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }, []); @@ -427,7 +498,8 @@ export function StructureView({ const togglePriority = useCallback((id: string) => { setActivePriorities((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }, []); @@ -437,57 +509,68 @@ export function StructureView({ setActiveCategories(new Set()); setActiveTags(new Set()); setActivePriorities(new Set()); - setSearch(''); + setSearch(""); }, []); const handleStartNewTask = useCallback((groupKey: TaskGroupKey) => { setAddingToGroup(groupKey); - setAddTitle(''); + setAddTitle(""); }, []); - const handleAddTask = useCallback(async (groupKey: TaskGroupKey) => { - const trimmed = addTitle.trim(); - if (!trimmed) { + const handleAddTask = useCallback( + async (groupKey: TaskGroupKey) => { + const trimmed = addTitle.trim(); + if (!trimmed) { + setAddingToGroup(null); + return; + } + const status: TaskStatus = + groupKey === "ready" + ? "planned" + : groupKey === "plannable" || groupKey === "cancelled" + ? "draft" + : (groupKey as TaskStatus); + await createTask({ + projectId, + title: trimmed, + description: "", + status, + order: tasks.length, + }); setAddingToGroup(null); - return; - } - const status: TaskStatus = groupKey === 'ready' - ? 'planned' - : groupKey === 'plannable' || groupKey === 'cancelled' - ? 'draft' - : (groupKey as TaskStatus); - await createTask({ - projectId, - title: trimmed, - description: '', - status, - order: tasks.length, - }); - setAddingToGroup(null); - setAddTitle(''); - onGraphChange?.(); - }, [addTitle, projectId, tasks.length, onGraphChange]); - - const handleRestore = useCallback(async (item: DeletedTask) => { - const t = item.taskData; - await createTask({ - projectId: t.projectId, - title: t.title, - description: t.description, - status: t.status, - order: tasks.length, - acceptanceCriteria: t.acceptanceCriteria, - decisions: t.decisions, - implementationPlan: t.implementationPlan, - executionRecord: t.executionRecord, - tags: t.tags, - category: t.category, - files: t.files, - }); - onGraphChange?.(); - }, [tasks.length, onGraphChange]); + setAddTitle(""); + onGraphChange?.(); + }, + [addTitle, projectId, tasks.length, onGraphChange], + ); - const { canUndo, push: pushUndo, undo } = useUndo({ + const handleRestore = useCallback( + async (item: DeletedTask) => { + const t = item.taskData; + await createTask({ + projectId: t.projectId, + title: t.title, + description: t.description, + status: t.status, + order: tasks.length, + acceptanceCriteria: t.acceptanceCriteria, + decisions: t.decisions, + implementationPlan: t.implementationPlan, + executionRecord: t.executionRecord, + tags: t.tags, + category: t.category, + files: t.files, + }); + onGraphChange?.(); + }, + [tasks.length, onGraphChange], + ); + + const { + canUndo, + push: pushUndo, + undo, + } = useUndo({ onUndo: handleRestore, keyboard: { panelSelector: '[data-panel="navigator"]' }, }); @@ -513,27 +596,30 @@ export function StructureView({ ); }, [confirmDelete, queryClient, projectId]); - const handleDelete = useCallback(async (taskId: string) => { - const slim = tasks.find((t) => t.id === taskId); - const bodyPromise = - pendingDeleteBodyRef.current.get(taskId) ?? - queryClient - .fetchQuery({ - queryKey: taskKeys.detail(projectId, taskId), - queryFn: fetchTaskBody(queryClient, projectId, taskId), - }) - .then((data) => (data as TaskFull | undefined) ?? null) - .catch(() => null); - // Await the GET before firing the DELETE so the server reads the - // pre-deletion state — a parallel `Promise.all` could let the DELETE - // win the race and the GET return 404. - const full = await bodyPromise; - pendingDeleteBodyRef.current.delete(taskId); - if (slim && full) pushUndo({ title: slim.title, taskData: full }); - await deleteTask(taskId); - setConfirmDelete(null); - onGraphChange?.(); - }, [tasks, pushUndo, onGraphChange, queryClient, projectId]); + const handleDelete = useCallback( + async (taskId: string) => { + const slim = tasks.find((t) => t.id === taskId); + const bodyPromise = + pendingDeleteBodyRef.current.get(taskId) ?? + queryClient + .fetchQuery({ + queryKey: taskKeys.detail(projectId, taskId), + queryFn: fetchTaskBody(queryClient, projectId, taskId), + }) + .then((data) => (data as TaskFull | undefined) ?? null) + .catch(() => null); + // Await the GET before firing the DELETE so the server reads the + // pre-deletion state — a parallel `Promise.all` could let the DELETE + // win the race and the GET return 404. + const full = await bodyPromise; + pendingDeleteBodyRef.current.delete(taskId); + if (slim && full) pushUndo({ title: slim.title, taskData: full }); + await deleteTask(taskId); + setConfirmDelete(null); + onGraphChange?.(); + }, + [tasks, pushUndo, onGraphChange, queryClient, projectId], + ); // Stable delete callbacks for `TaskRow` — keeping these tight is what // makes `React.memo(TaskRow)` useful. Identity stays stable across @@ -541,14 +627,22 @@ export function StructureView({ const handleRequestDelete = useCallback((id: string) => { setConfirmDelete(id); }, []); - const handleConfirmDelete = useCallback((id: string) => { - void handleDelete(id); - }, [handleDelete]); + const handleConfirmDelete = useCallback( + (id: string) => { + void handleDelete(id); + }, + [handleDelete], + ); const handleCancelDelete = useCallback(() => { setConfirmDelete(null); }, []); - const totalActiveFilters = activeStatuses.size + activeCategories.size + activeTags.size + activePriorities.size + (search.trim() ? 1 : 0); + const totalActiveFilters = + activeStatuses.size + + activeCategories.size + + activeTags.size + + activePriorities.size + + (search.trim() ? 1 : 0); // Flatten the grouped sections into a single sequence so the virtualizer // can size and position each visible row independently. Group headers @@ -556,23 +650,23 @@ export function StructureView({ const flatItems = useMemo(() => { const items: RowItem[] = []; for (const [section, groupTasks] of groupedVisible) { - if (section.kind !== 'flat') { + if (section.kind !== "flat") { items.push({ - kind: 'group-header', + kind: "group-header", key: `h:${sectionKey(section)}`, section, count: groupTasks.length, }); } - if (section.kind === 'status' && addingToGroup === section.key) { + if (section.kind === "status" && addingToGroup === section.key) { items.push({ - kind: 'new-task-input', + kind: "new-task-input", key: `n:${section.key}`, groupKey: section.key, }); } for (const t of groupTasks) { - items.push({ kind: 'task', key: t.id, task: t }); + items.push({ kind: "task", key: t.id, task: t }); } } return items; @@ -589,7 +683,8 @@ export function StructureView({ const virtualizer = useVirtualizer({ count: flatItems.length, getScrollElement: () => scrollRef.current, - estimateSize: (index) => (flatItems[index]?.kind === 'group-header' ? 32 : 35), + estimateSize: (index) => + flatItems[index]?.kind === "group-header" ? 32 : 35, getItemKey: (index) => flatItems[index]?.key ?? index, overscan: 8, }); @@ -603,9 +698,11 @@ export function StructureView({ if (didScrollToSelectionRef.current) return; if (!selectedNodeId) return; if (flatItems.length === 0) return; - const idx = flatItems.findIndex((it) => it.kind === 'task' && it.task.id === selectedNodeId); + const idx = flatItems.findIndex( + (it) => it.kind === "task" && it.task.id === selectedNodeId, + ); if (idx < 0) return; - virtualizer.scrollToIndex(idx, { align: 'center' }); + virtualizer.scrollToIndex(idx, { align: "center" }); didScrollToSelectionRef.current = true; }, [selectedNodeId, flatItems, virtualizer]); @@ -637,11 +734,13 @@ export function StructureView({ {canUndo && ( - Task deleted + + Task deleted + )} @@ -656,8 +755,8 @@ export function StructureView({
{virtualizer.getVirtualItems().map((vi) => { @@ -668,33 +767,44 @@ export function StructureView({ key={vi.key} data-index={vi.index} style={{ - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, transform: `translateY(${vi.start}px)`, }} > - {item.kind === 'group-header' && ( + {item.kind === "group-header" && ( handleStartNewTask((item.section as Extract).key) + item.section.kind === "status" + ? () => + handleStartNewTask( + ( + item.section as Extract< + GroupSection, + { kind: "status" } + > + ).key, + ) : undefined } /> )} - {item.kind === 'new-task-input' && ( + {item.kind === "new-task-input" && ( handleAddTask(item.groupKey)} - onCancel={() => { setAddingToGroup(null); setAddTitle(''); }} + onCancel={() => { + setAddingToGroup(null); + setAddTitle(""); + }} /> )} - {item.kind === 'task' && ( + {item.kind === "task" && ( @@ -786,11 +898,16 @@ function TaskGroupHeader({ section, count, onAdd }: TaskGroupHeaderProps) { } return (
-
); } @@ -803,8 +920,8 @@ function TaskGroupHeader({ section, count, onAdd }: TaskGroupHeaderProps) { * @returns String key. */ function sectionKey(section: GroupSection): string { - if (section.kind === 'flat') return '__flat__'; - if (section.kind === 'status') return `status:${section.key}`; + if (section.kind === "flat") return "__flat__"; + if (section.kind === "status") return `status:${section.key}`; return `category:${section.key}`; } @@ -829,7 +946,10 @@ function NewTaskRow({ value, onChange, onCommit, onCancel }: NewTaskRowProps) { const cancelRef = useRef(false); return (
-
); } @@ -864,7 +992,9 @@ function EmptyTasks() { return (

No tasks yet

-

Use the chat or your CLI agent to draft tasks.

+

+ Use the chat or your CLI agent to draft tasks. +

); } @@ -887,7 +1017,10 @@ interface SearchRowProps { function SearchRow({ value, onChange }: SearchRowProps) { return (
-