Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2379-projects-lists-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a Lists tab to the Projects app: a rail of the project's lists beside an entry panel with quick-add, done toggles, category and status pills, a status selector, and the original text behind any entry an agent tidied.
356 changes: 356 additions & 0 deletions desktop/src/apps/ProjectsApp/ProjectLists.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { projectsApi, type Project, type ProjectList, type ProjectListEntry } from "@/lib/projects";
import styles from "./ProjectsApp.module.css";

const STATUS_STYLE: Record<string, string> = {
new: "bg-blue-500/15 text-blue-400",
seen: "bg-shell-bg-deep text-shell-text-tertiary",
actioned: "bg-green-500/15 text-green-400",
discuss: "bg-amber-500/15 text-amber-400",
};

const EMPTY_LIST: ProjectList = { id: "", project_id: "", title: "", description: "", status: "active", created_by: "", created_at: 0, updated_at: 0 };

export function ProjectLists({ project }: { project: Project }) {
const [lists, setLists] = useState<ProjectList[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [entries, setEntries] = useState<ProjectListEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [quickText, setQuickText] = useState("");
const [submitting, setSubmitting] = useState(false);
const quickInputRef = useRef<HTMLInputElement>(null);
// Current selection, readable from inside an async closure that captured an
// older one. Assigned during render so it is correct before any effect runs.
const selectedListIdRef = useRef<string | null>(selectedListId);
selectedListIdRef.current = selectedListId;

// Drop the previous project's selection DURING RENDER, not in an effect.
// ProjectWorkspace renders <ProjectLists project={project} /> unkeyed and is
// itself unkeyed, so a project switch reuses this component. Resetting in an
// effect is too late: the entries effect re-runs in the same commit (its deps
// include project.id) and would fetch
// /api/projects/<new>/lists/<old-list-id>/entries first. This is React's
// adjust-state-on-prop-change pattern and it runs before any effect.
const [prevProjectId, setPrevProjectId] = useState(project.id);
if (prevProjectId !== project.id) {
setPrevProjectId(project.id);
setSelectedListId(null);
setEntries([]);
}

const refreshLists = useCallback(() => {
// Sets state, like refreshEntries below. It used to only RETURN the lists,
// so createList/deleteList awaited a fetch whose result was discarded -- a
// created list never appeared in the rail and a deleted one never left it,
// because the mount effect was the only caller of setLists.
return projectsApi.lists.list(project.id)
.then((ls) => {
setLists(ls);
return ls;
})
.catch(() => {
setError("Could not load lists.");
return [] as ProjectList[];
});
}, [project.id]);

const refreshEntries = useCallback(() => {
if (!selectedListId) return Promise.resolve([] as ProjectListEntry[]);
const forListId = selectedListId;
return projectsApi.lists.entries.list(project.id, forListId)
.then((ents) => {
// Drop a response whose list is no longer selected. Two quick clicks
// race, and the slower fetch used to land last and show one list's
// entries under another's heading. The ref is the CURRENT selection;
// this closure's own copy is stale by definition.
if (forListId !== selectedListIdRef.current) return ents;
setEntries(ents);
return ents;
})
.catch(() => {
setError("Could not load entries.");
return [] as ProjectListEntry[];
});
}, [project.id, selectedListId]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
refreshLists()
.then((ls) => {
if (!cancelled) {
setLists(ls);
// Keep the selection only if it belongs to THIS project's lists.
// ProjectWorkspace renders <ProjectLists project={project} /> with no
// key and is itself unkeyed, so switching project does not remount:
// the previous project's selectedListId survived and the entries
// effect then fetched /api/projects/<new>/lists/<old-id>/entries.
// The functional form also avoids depending on a stale closure value.
setSelectedListId((prev) =>
prev && ls.some((l) => l.id === prev) ? prev : (ls[0]?.id ?? null));
}
})
.catch(() => {
if (!cancelled) setError("Could not load lists for this project.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [project.id, refreshLists]);

useEffect(() => {
if (!selectedListId) { setEntries([]); return; }
let cancelled = false;
setLoading(true);
refreshEntries().finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [selectedListId, refreshEntries]);

const createList = async () => {
// Trim BEFORE the empty check: " " is truthy, so a whitespace-only
// answer used to reach the API as an empty title.
const title = prompt("New list name")?.trim();
if (!title) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: createList accepts whitespace-only titles

if (!title) return; does not catch inputs like " ". After title.trim(), the resulting empty string is passed to the API, potentially creating a list with an empty title. Use if (!title.trim()) return; instead.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

try {
const created = await projectsApi.lists.create(project.id, { title });
await refreshLists();
setSelectedListId(created.id);
} catch (err) {
setError(String(err));
}
};

const deleteList = async (listId: string) => {
if (!confirm("Delete this list and all its entries?")) return;
try {
await projectsApi.lists.remove(project.id, listId);
if (selectedListId === listId) setSelectedListId(null);
await refreshLists();
} catch (err) {
setError(String(err));
}
};

const createEntry = async (e: React.FormEvent) => {
e.preventDefault();
const text = quickText.trim();
if (!text || submitting || !selectedListId) return;
setSubmitting(true);
try {
await projectsApi.lists.entries.create(project.id, selectedListId, { text });
setQuickText("");
await refreshEntries();
quickInputRef.current?.focus();
} catch (err) {
setError(String(err));
} finally {
setSubmitting(false);
}
};

const toggleDone = async (entry: ProjectListEntry) => {
try {
await projectsApi.lists.entries.update(project.id, entry.list_id, entry.id, { done: entry.done ? 0 : 1 });
await refreshEntries();
} catch (err) {
setError(String(err));
}
};

const updateEntryStatus = async (entry: ProjectListEntry, status: string) => {
try {
await projectsApi.lists.entries.update(project.id, entry.list_id, entry.id, { status });
await refreshEntries();
} catch (err) {
setError(String(err));
}
};

const removeEntry = async (entry: ProjectListEntry) => {
if (!confirm("Remove this entry?")) return;
try {
await projectsApi.lists.entries.remove(project.id, entry.list_id, entry.id);
await refreshEntries();
} catch (err) {
setError(String(err));
}
};

const selectedList = lists.find((l) => l.id === selectedListId) ?? EMPTY_LIST;

return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-1 min-h-0 gap-3">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stack the list panels on mobile screens.

Below 768px, .listsRail is width: 100% and flex: none, but this container remains a horizontal flex row. The rail consumes the available row width, so the entries panel has no usable width.

Proposed fix
-      <div className="flex flex-1 min-h-0 gap-3">
+      <div className="flex flex-1 min-h-0 flex-col gap-3 md:flex-row">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex flex-1 min-h-0 gap-3">
<div className="flex flex-1 min-h-0 flex-col gap-3 md:flex-row">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src/apps/ProjectsApp/ProjectLists.tsx` at line 144, Update the
container div in ProjectLists so its flex direction changes to a vertical column
below the 768px breakpoint, while preserving the existing horizontal row layout
on larger screens. Ensure the listsRail and entries panel stack with usable
width on mobile.

<aside className={styles.listsRail} aria-label="Lists">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-shell-text">Lists</h2>
<button
type="button"
onClick={createList}
aria-label="Create new list"
className="rounded-full bg-accent/10 px-2 py-1 text-xs font-medium text-accent hover:bg-accent/20"
>
+ New
</button>
</div>
{loading && lists.length === 0 ? (
<p className="text-xs text-shell-text-tertiary">Loading…</p>
) : lists.length === 0 ? (
<p className="text-xs text-shell-text-secondary">No lists yet.</p>
) : (
<ul className="flex flex-col gap-1" role="listbox" aria-label="Project lists">
{lists.map((lst) => (
<li
key={lst.id}
role="option"
aria-selected={lst.id === selectedListId}
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelectedListId(lst.id); } }}
onClick={() => setSelectedListId(lst.id)}
className={`flex items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-sm cursor-pointer transition-colors ${lst.id === selectedListId ? "bg-shell-surface-active text-shell-text" : "text-shell-text-secondary hover:bg-shell-surface-hover hover:text-shell-text"}`}
>
<span className="truncate">{lst.title || "Untitled list"}</span>
<button
type="button"
aria-label={`Delete ${lst.title || "Untitled list"}`}
onClick={(e) => { e.stopPropagation(); deleteList(lst.id); }}
className="rounded p-0.5 text-shell-text-tertiary hover:text-red-400"
>
×
</button>
</li>
))}
</ul>
)}
</aside>

<main className={styles.listsEntriesPanel} aria-label="List entries">
{!selectedListId ? (
<p className="text-sm text-shell-text-secondary">Select or create a list.</p>
) : (
<div className="flex flex-col gap-3 h-full">
<header className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-shell-text truncate">{selectedList.title || "Untitled list"}</h3>
{selectedList.description && (
<span className="text-xs text-shell-text-tertiary truncate" title={selectedList.description}>{selectedList.description}</span>
)}
</header>

<form onSubmit={createEntry} className="flex gap-2">
<label htmlFor={`quick-add-${selectedList.id}`} className="sr-only">Add entry to {selectedList.title || "Untitled list"}</label>
<input
ref={quickInputRef}
id={`quick-add-${selectedList.id}`}
type="text"
value={quickText}
onChange={(e) => setQuickText(e.target.value)}
placeholder="Add entry…"
disabled={submitting}
aria-label={`Quick add entry to ${selectedList.title || "Untitled list"}`}
className="flex-1 rounded-lg border border-shell-border bg-shell-surface px-3 py-1.5 text-sm text-shell-text outline-none focus:ring-2 focus:ring-accent-line disabled:opacity-50"
/>
<button
type="submit"
disabled={submitting || !quickText.trim()}
aria-label="Add entry"
className="rounded-lg bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/20 disabled:opacity-50"
>
{submitting ? "Adding…" : "Add"}
</button>
</form>

{error && (
<p role="alert" className="text-xs text-red-400">{error}</p>
)}

{loading ? (
<p className="text-sm text-shell-text-tertiary">Loading entries…</p>
) : entries.length === 0 ? (
<p className="text-sm text-shell-text-secondary">No entries yet. Type above and press Enter to add one.</p>
) : (
<ul className="flex flex-col gap-2" aria-label="Entries">
{entries.map((entry) => {
const tidied = entry.text !== entry.original_text;
return (
<li
key={entry.id}
className="flex flex-col gap-1.5 rounded-xl border border-shell-border bg-shell-surface p-3.5"
>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-shell-text-secondary">
<input
type="checkbox"
checked={!!entry.done}
onChange={() => toggleDone(entry)}
aria-label={`Mark ${entry.text} as done`}
className="rounded border-shell-border"
/>
Done
</label>
{entry.category && (
<span className="inline-flex items-center rounded-full bg-shell-border px-2 py-0.5 text-xs font-medium text-shell-text-secondary">
{entry.category}
</span>
)}
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize ${STATUS_STYLE[entry.status] ?? "bg-shell-border text-shell-text-secondary"}`}
>
{entry.status}
</span>
{tidied && (
<span
className="inline-flex items-center gap-1 text-xs text-shell-text-tertiary cursor-pointer hover:text-shell-text"
title={entry.original_text}
tabIndex={0}
role="button"
aria-label={`View original text for ${entry.text}`}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); alert(entry.original_text); } }}
onClick={() => alert(entry.original_text)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Replace alert() with a non-blocking UI pattern

alert() blocks the main thread and is an intrusive UX pattern. The title attribute already surfaces original_text on hover; consider a tooltip, popover, or inline expansion instead.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
original
</span>
)}
<div className="ml-auto flex gap-2 text-xs">
<select
value={entry.status}
onChange={(e) => updateEntryStatus(entry, e.target.value)}
aria-label={`Change status for ${entry.text}`}
className="rounded bg-shell-bg-deep px-1.5 py-0.5 text-xs text-shell-text border border-shell-border"
>
<option value="new">new</option>
<option value="seen">seen</option>
<option value="actioned">actioned</option>
<option value="discuss">discuss</option>
</select>
<button
type="button"
onClick={() => removeEntry(entry)}
aria-label={`Delete entry ${entry.text}`}
className="text-red-400 hover:underline"
>
Delete
</button>
</div>
</div>
<p className={`text-sm ${entry.done ? "line-through text-shell-text-tertiary" : "text-shell-text"}`}>
{entry.text}
</p>
</li>
);
})}
</ul>
)}
</div>
)}
</main>
</div>
</div>
);
}
8 changes: 5 additions & 3 deletions desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import { ElementCreateDialog } from "./elements/ElementCreateDialog";
import styles from "./ProjectsApp.module.css";

import { CommunityView } from "./CommunityView";
import { ProjectLists } from "./ProjectLists";

export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines" | "community";
const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines", "community"];
export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines" | "community" | "lists";
const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines", "community", "lists"];

function isTab(value: string | undefined): value is Tab {
return value != null && (TABS as string[]).includes(value);
Expand Down Expand Up @@ -105,7 +106,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: {
// Mobile pill order: surface Messages right after Workspace so it is reachable
// without scrolling (on mobile Messages is its own full page, not a squeezed
// pane inside Workspace).
const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines", "community"];
const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines", "community", "lists"];
const tabPills = mobileTabOrder.map((t) => ({
id: t,
label: t.charAt(0).toUpperCase() + t.slice(1),
Expand Down Expand Up @@ -390,6 +391,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: {
{tab === "decisions" && <ProjectDecisions projectId={project.id} />}
{tab === "routines" && <ProjectRoutines project={project} />}
{tab === "community" && <CommunityView projectId={project.id} />}
{tab === "lists" && <ProjectLists project={project} />}
</div>

{isMobile && (tab === "tasks" || tab === "board") && (
Expand Down
Loading
Loading