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
24 changes: 24 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ _Avoid_: Focus thread, focus window
A sidebar thread indicator that shows Change Request state and glanceable metadata for the Thread's linked Change Request.
_Avoid_: PR icon, badge

**Change Request Review**:
A sub-mode of a Thread that provides an interactive diff review experience for the Change Request linked to that Thread, allowing inline comments, AI-assisted review responses, and GitHub review submission — all inside the app instead of on the Git provider's website.
_Avoid_: PR review mode, review panel

**Review Draft**:
The locally cached set of Review Comments that have been authored but not yet posted to the Git provider. Persists across server restarts and is shared by all Threads linked to the same Change Request.
_Avoid_: Pending comments, local review state

**Review Comment**:
An inline comment anchored to a file, optional line number, and commit SHA within a Change Request Review.
_Avoid_: Inline review, PR note

**Background Review Agent**:
A short-lived AI agent spawned from a Review Comment to respond to or act on that comment. Runs in an isolated temp worktree, pushes commits directly to the Change Request branch, and streams responses back inline.
_Avoid_: Review bot, response agent

**Delegation Badge**:
A sidebar thread indicator that marks a Worker Thread as delegated from the Manager Console.
_Avoid_: Agent badge, child badge
Expand Down Expand Up @@ -143,6 +159,14 @@ _Avoid_: Project metadata, browser-only setting
- A **Focus Chat Shortcut** targets the **Current Thread**
- A **Logical Project Grouping** can combine multiple physical **Projects** into one sidebar presentation
- A **Change Request Badge** belongs to a **Thread** when that Thread is linked to a Change Request
- A **Change Request Review** belongs to a **Thread** that is linked to a Change Request
- A **Change Request Review** contains many **Review Comments**
- A **Review Draft** belongs to a **Change Request Review**
- A **Review Comment** is anchored to a file, optionally a line, and a commit SHA
- A **Review Comment** can spawn a **Background Review Agent**
- A **Background Review Agent** runs in an isolated temp worktree
- A **Background Review Agent** pushes commits to the Change Request branch
- A **Change Request Review** is shared across all **Threads** linked to the same Change Request
- A **Delegation Badge** belongs to a **Worker Thread**

## Example dialogue
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/todoPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const TODOS_ARCHIVE_PATH = T3CODE_DIR + "/todos-archive.json";
export interface TodosData {
categories: TodoCategory[];
items: TodoItem[];
jiraBaseUrl?: string;
}

export const readTodos = Effect.gen(function* () {
Expand Down
18 changes: 15 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,15 +1013,27 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
Effect.gen(function* () {
const current = yield* readTodos;
const next = applyMutations(
{ categories: current.categories, items: current.items },
{
categories: current.categories,
items: current.items,
jiraBaseUrl: current.jiraBaseUrl,
},
input.mutations,
);
const { state: deduped, archived } = archiveDoneItems(next);
if (archived.length > 0) {
yield* appendToArchive(archived);
}
yield* writeTodos({ categories: deduped.categories, items: deduped.items });
return { categories: deduped.categories, items: deduped.items };
yield* writeTodos({
categories: deduped.categories,
items: deduped.items,
jiraBaseUrl: deduped.jiraBaseUrl,
});
return {
categories: deduped.categories,
items: deduped.items,
jiraBaseUrl: deduped.jiraBaseUrl,
};
}).pipe(
Effect.mapError(
(cause) =>
Expand Down
180 changes: 165 additions & 15 deletions apps/web/src/components/TodoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
Expand All @@ -24,6 +24,7 @@ import {
EyeOffIcon,
GripVerticalIcon,
PlusIcon,
SettingsIcon,
} from "lucide-react";
import type { TodoCategory, TodoItem, ContextMenuItem, TodoMutation } from "@t3tools/contracts";
import { ensureLocalApi } from "~/localApi";
Expand All @@ -43,11 +44,56 @@ const COLOR_PALETTE = [
"#6366f1",
];

function priorityBgColor(priority: string): string {
switch (priority) {
case "high":
return "#ef444420";
case "medium":
return "#f59e0b20";
case "low":
return "#3b82f620";
default:
return "transparent";
}
}

function priorityFgColor(priority: string): string {
switch (priority) {
case "high":
return "#ef4444";
case "medium":
return "#f59e0b";
case "low":
return "#3b82f6";
default:
return "inherit";
}
}

function extractJiraKey(url: string): string | null {
const match = url.match(/([A-Z]+-\d+)$/);
return match ? (match[1] ?? null) : null;
}

function isJiraKey(text: string): boolean {
return /^[A-Z]+-\d+$/.test(text.trim());
}

function resolveJiraUrl(input: string, baseUrl: string | undefined): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (isJiraKey(trimmed) && baseUrl) {
const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
return base + trimmed;
}
if (/^https?:\/\//.test(trimmed)) return trimmed;
if (baseUrl) {
const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
return base + trimmed;
}
return trimmed;
}

function countActiveItems(items: TodoItem[], categoryId: string): number {
return items.filter(
(item) =>
Expand All @@ -56,7 +102,7 @@ function countActiveItems(items: TodoItem[], categoryId: string): number {
}

export function TodoPanel() {
const { categories, items, loading, error, mutate } = useTodos();
const { categories, items, jiraBaseUrl, loading, error, mutate } = useTodos();
const [hideEmpty, setHideEmpty] = useState(false);
const [draggedItem, setDraggedItem] = useState<TodoItem | null>(null);
const [draggedCategory, setDraggedCategory] = useState<TodoCategory | null>(null);
Expand Down Expand Up @@ -215,6 +261,21 @@ export function TodoPanel() {
<SidebarHeader className="flex-row items-center gap-2 px-3 py-2 border-b border-border">
<span className="text-sm font-medium">Todos</span>
<div className="flex-1" />
<button
className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
const url = window.prompt(
"Enter JIRA base URL (e.g., https://company.atlassian.net/browse):",
jiraBaseUrl ?? "",
);
if (url !== null) {
mutate([{ type: "setJiraBaseUrl", jiraBaseUrl: url.trim() }]);
}
}}
title={jiraBaseUrl ? `JIRA base: ${jiraBaseUrl}` : "Set JIRA base URL"}
>
<SettingsIcon size={14} />
</button>
<button
className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => setHideEmpty((prev) => !prev)}
Expand Down Expand Up @@ -248,6 +309,7 @@ export function TodoPanel() {
key={category.id}
category={category}
items={items}
jiraBaseUrl={jiraBaseUrl}
mutate={mutate}
onCycleItem={handleCycleItem}
onSelectItem={setSelectedItemId}
Expand Down Expand Up @@ -388,13 +450,15 @@ function SortableTodoItem({
item,
categoryColor,
categoryJiraLink,
jiraBaseUrl,
onCycleItem,
onSelectItem,
mutate,
}: {
item: TodoItem;
categoryColor: string;
categoryJiraLink: string | undefined;
jiraBaseUrl: string | undefined;
onCycleItem: (itemId: string) => void;
onSelectItem: (itemId: string) => void;
mutate: (mutations: TodoMutation[]) => Promise<void>;
Expand All @@ -404,12 +468,51 @@ function SortableTodoItem({
data: { kind: "item", categoryId: item.categoryId },
});

const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(item.title);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};

const handleTitleClick = () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
setEditValue(item.title);
setEditing(true);
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
onSelectItem(item.id);
}, 200);
}
};

const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
commitEdit();
} else if (e.key === "Escape") {
cancelEdit();
}
};

const commitEdit = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== item.title) {
mutate([{ type: "renameItem", itemId: item.id, title: trimmed }]);
}
setEditing(false);
};

const cancelEdit = () => {
setEditing(false);
setEditValue(item.title);
};

const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();

Expand All @@ -418,6 +521,15 @@ function SortableTodoItem({
void api.contextMenu
.show(
[
{
id: "priority",
label: "Priority",
children: [
{ id: `priority:low:${item.id}`, label: "Low" },
{ id: `priority:medium:${item.id}`, label: "Medium" },
{ id: `priority:high:${item.id}`, label: "High" },
],
},
{
id: `jira:${item.id}`,
label: item.jiraLink ? "Change JIRA Link" : "Edit JIRA Link",
Expand All @@ -436,12 +548,17 @@ function SortableTodoItem({
.then((clicked) => {
if (!clicked) return;

if (clicked === `jira:${item.id}`) {
const url = window.prompt("Enter JIRA issue URL:", item.jiraLink ?? "");
if (clicked.startsWith("priority:")) {
const priority = clicked.split(":")[1] as "low" | "medium" | "high";
if (priority) {
mutate([{ type: "setItemPriority", itemId: item.id, priority }]);
}
} else if (clicked === `jira:${item.id}`) {
const url = window.prompt("Enter JIRA issue URL or key:", item.jiraLink ?? "");
if (url !== null) {
const trimmed = url.trim();
if (trimmed) {
mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: trimmed }]);
const resolved = resolveJiraUrl(url, jiraBaseUrl);
if (resolved) {
mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: resolved }]);
} else {
mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: "" }]);
}
Expand Down Expand Up @@ -477,9 +594,32 @@ function SortableTodoItem({
>
<StatusIcon status={item.status} />
</button>
<span className="truncate cursor-pointer" onClick={() => onSelectItem(item.id)}>
{item.title}
<span className="truncate cursor-pointer" onClick={handleTitleClick}>
{editing ? (
<input
className="bg-transparent border-b border-primary outline-none text-xs w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
autoFocus
/>
) : (
item.title
)}
</span>
{item.priority && (
<span
className="shrink-0 text-[10px] px-1 py-0.5 rounded font-medium uppercase"
style={{
backgroundColor: priorityBgColor(item.priority),
color: priorityFgColor(item.priority),
}}
title={`Priority: ${item.priority}`}
>
{item.priority}
</span>
)}
{jiraKey && (
<a
href={effectiveJiraLink}
Expand All @@ -502,17 +642,23 @@ function SortableTodoItem({
function TodoCategoryRow({
category,
items,
jiraBaseUrl,
mutate,
onCycleItem,
onSelectItem,
}: {
category: TodoCategory;
items: TodoItem[];
jiraBaseUrl: string | undefined;
mutate: (mutations: TodoMutation[]) => Promise<void>;
onCycleItem: (itemId: string) => void;
onSelectItem: (itemId: string) => void;
}) {
const [collapsed, setCollapsed] = useState(category.collapsed === true);

useEffect(() => {
setCollapsed(category.collapsed === true);
}, [category.collapsed]);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(category.name);
const [adding, setAdding] = useState(false);
Expand Down Expand Up @@ -619,12 +765,12 @@ function TodoCategoryRow({
const color = clicked.slice("color:".length);
mutate([{ type: "setCategoryColor", categoryId: category.id, color }]);
} else if (clicked === "jira") {
const url = window.prompt("Enter JIRA issue URL:", category.jiraLink ?? "");
const url = window.prompt("Enter JIRA issue URL or key:", category.jiraLink ?? "");
if (url !== null) {
const trimmed = url.trim();
if (trimmed) {
const resolved = resolveJiraUrl(url, jiraBaseUrl);
if (resolved) {
mutate([
{ type: "setCategoryJiraLink", categoryId: category.id, jiraLink: trimmed },
{ type: "setCategoryJiraLink", categoryId: category.id, jiraLink: resolved },
]);
}
}
Expand Down Expand Up @@ -657,7 +803,10 @@ function TodoCategoryRow({
<SortableCategoryHeader
category={category}
collapsed={collapsed}
onToggle={() => setCollapsed((prev) => !prev)}
onToggle={() => {
setCollapsed((prev) => !prev);
mutate([{ type: "toggleCategory", categoryId: category.id }]);
}}
renaming={renaming}
renameValue={renameValue}
onRenameChange={setRenameValue}
Expand Down Expand Up @@ -693,6 +842,7 @@ function TodoCategoryRow({
item={item}
categoryColor={category.color}
categoryJiraLink={category.jiraLink}
jiraBaseUrl={jiraBaseUrl}
onCycleItem={onCycleItem}
onSelectItem={onSelectItem}
mutate={mutate}
Expand Down Expand Up @@ -777,7 +927,7 @@ function ItemDetailPanel({
</div>
) : item.description ? (
<div
className="text-xs prose prose-sm max-w-none cursor-pointer"
className="text-xs todo-markdown max-w-none cursor-pointer"
onClick={handleStartEdit}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.description}</ReactMarkdown>
Expand Down
Loading
Loading