+
{/* Worktree not registered warning */}
{isWorktreeRegistered === false && worktreePath && (
@@ -143,16 +143,42 @@ export const HistoryView = memo(function HistoryView({
)}
- {/* Commits list - only commits, files are shown in right panel */}
- {commits.map((commit, index) => (
-
handleCommitClick(commit)}
- />
- ))}
+ {/* Commits list — fixed ~40% of the pane so the diff split has room. */}
+
+ {commits.map((commit, index) => (
+ handleCommitClick(commit)}
+ />
+ ))}
+
+
+ {/* Two-column file list + diff for the selected commit */}
+ {selectedCommitHash && (
+ isLoadingFiles && !commitFiles ? (
+
+ Loading files…
+
+ ) : filesError ? (
+
+ Failed to load files: {filesError.message}
+
+ ) : (
+ handleFileClick(file)}
+ />
+ )
+ )}
);
});
@@ -245,41 +271,3 @@ const HistoryCommitItem = memo(function HistoryCommitItem({
);
});
-const CommitFileItem = memo(function CommitFileItem({
- file,
- isSelected,
- onClick,
-}: {
- file: ChangedFile;
- isSelected: boolean;
- onClick: () => void;
-}) {
- const fileName = file.path.split("/").pop() || file.path;
- const dirPath = file.path.includes("/")
- ? file.path.substring(0, file.path.lastIndexOf("/"))
- : "";
-
- return (
-
-
-
- {dirPath && (
-
- {dirPath}/
-
- )}
-
- {fileName}
-
-
-
{getStatusIndicator(file.status)}
-
- );
-});
diff --git a/src/renderer/features/changes/components/pull-push-dialog.tsx b/src/renderer/features/changes/components/pull-push-dialog.tsx
new file mode 100644
index 000000000..2bba88790
--- /dev/null
+++ b/src/renderer/features/changes/components/pull-push-dialog.tsx
@@ -0,0 +1,82 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { trpc } from "../../../lib/trpc";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogBody,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "../../../components/ui/alert-dialog";
+
+interface PullPushDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ worktreePath: string | null | undefined;
+ setUpstream: boolean;
+ onSuccess?: () => void;
+}
+
+export function PullPushDialog({
+ open,
+ onOpenChange,
+ worktreePath,
+ setUpstream,
+ onSuccess,
+}: PullPushDialogProps) {
+ const [isWorking, setIsWorking] = useState(false);
+ const pullMutation = trpc.changes.pull.useMutation();
+ const pushMutation = trpc.changes.push.useMutation();
+
+ const handlePullAndPush = async () => {
+ if (!worktreePath) return;
+ setIsWorking(true);
+ try {
+ await pullMutation.mutateAsync({ worktreePath, autoStash: true });
+ await pushMutation.mutateAsync({ worktreePath, setUpstream });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ toast.error(`Pull & push failed: ${message}`);
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ return (
+
+
+
+ Remote has new commits
+
+ Your push was rejected because the remote branch has commits you
+ don't have locally.
+
+
+
+
+ Pull with rebase and push in one step. Any uncommitted changes will
+ be auto-stashed and restored.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ void handlePullAndPush();
+ }}
+ disabled={isWorking || !worktreePath}
+ >
+ {isWorking ? "Working…" : "Pull & Push"}
+
+
+
+
+ );
+}
diff --git a/src/renderer/features/changes/hooks/use-push-action.ts b/src/renderer/features/changes/hooks/use-push-action.ts
index ccbe7ba3c..807f9041a 100644
--- a/src/renderer/features/changes/hooks/use-push-action.ts
+++ b/src/renderer/features/changes/hooks/use-push-action.ts
@@ -1,6 +1,9 @@
-import { useCallback } from "react";
+import { createElement, useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { trpc } from "../../../lib/trpc";
+import { PullPushDialog } from "../components/pull-push-dialog";
+
+const REMOTE_AHEAD_MARKER = "REMOTE_AHEAD:";
interface UsePushActionOptions {
worktreePath?: string | null;
@@ -13,11 +16,19 @@ export function usePushAction({
hasUpstream = true,
onSuccess,
}: UsePushActionOptions) {
+ const [dialogOpen, setDialogOpen] = useState(false);
+
const pushMutation = trpc.changes.push.useMutation({
onSuccess: () => {
onSuccess?.();
},
- onError: (error) => toast.error(`Push failed: ${error.message}`),
+ onError: (error) => {
+ if (error.message.startsWith(REMOTE_AHEAD_MARKER)) {
+ setDialogOpen(true);
+ return;
+ }
+ toast.error(`Push failed: ${error.message}`);
+ },
});
const push = useCallback(() => {
@@ -28,5 +39,13 @@ export function usePushAction({
pushMutation.mutate({ worktreePath, setUpstream: !hasUpstream });
}, [worktreePath, hasUpstream, pushMutation]);
- return { push, isPending: pushMutation.isPending };
+ const dialog: ReactNode = createElement(PullPushDialog, {
+ open: dialogOpen,
+ onOpenChange: setDialogOpen,
+ worktreePath,
+ setUpstream: !hasUpstream,
+ onSuccess,
+ });
+
+ return { push, isPending: pushMutation.isPending, dialog };
}
diff --git a/src/renderer/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts
index 787e05627..f91986020 100644
--- a/src/renderer/features/details-sidebar/atoms/index.ts
+++ b/src/renderer/features/details-sidebar/atoms/index.ts
@@ -2,14 +2,22 @@ import { atom } from "jotai"
import { atomFamily, atomWithStorage } from "jotai/utils"
import { atomWithWindowStorage } from "../../../lib/window-storage"
import type { LucideIcon } from "lucide-react"
-import { Box, FileText, Terminal, FileDiff, ListTodo } from "lucide-react"
+import {
+ Box,
+ FileText,
+ Terminal,
+ FileDiff,
+ ListTodo,
+ GitPullRequest,
+ Activity,
+} from "lucide-react"
import { OriginalMCPIcon } from "../../../components/ui/icons"
// ============================================================================
// Widget System Types & Registry
// ============================================================================
-export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp"
+export type WidgetId = "info" | "tasks" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr"
export interface WidgetConfig {
id: WidgetId
@@ -21,6 +29,8 @@ export interface WidgetConfig {
export const WIDGET_REGISTRY: WidgetConfig[] = [
{ id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true },
+ { id: "pr", label: "Pull Request", icon: GitPullRequest, canExpand: false, defaultVisible: false },
+ { id: "tasks", label: "Tasks", icon: Activity, canExpand: false, defaultVisible: true },
{ id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true },
{ id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true },
{ id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false },
diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx
index 6d5b8d016..8835eecf2 100644
--- a/src/renderer/features/details-sidebar/details-sidebar.tsx
+++ b/src/renderer/features/details-sidebar/details-sidebar.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
-import { ArrowUpRight, TerminalSquare, Box, ListTodo } from "lucide-react"
+import { ArrowUpRight, TerminalSquare, Box, ListTodo, GitPullRequest, Activity } from "lucide-react"
import { ResizableSidebar } from "@/components/ui/resizable-sidebar"
import { Button } from "@/components/ui/button"
import {
@@ -35,10 +35,12 @@ import {
import { WidgetSettingsPopup } from "./widget-settings-popup"
import { InfoSection } from "./sections/info-section"
import { TodoWidget } from "./sections/todo-widget"
+import { TasksWidget } from "./sections/tasks-widget"
import { PlanWidget } from "./sections/plan-widget"
import { TerminalWidget } from "./sections/terminal-widget"
import { ChangesWidget } from "./sections/changes-widget"
import { McpWidget } from "./sections/mcp-widget"
+import { PrWidget } from "./sections/pr-widget"
import { FilesTab, type FilesTabHandle } from "./sections/files-tab"
import type { ParsedDiffFile } from "./types"
import { fileViewerOpenAtomFamily, type AgentMode } from "../agents/atoms"
@@ -55,6 +57,8 @@ function getWidgetIcon(widgetId: WidgetId) {
switch (widgetId) {
case "info":
return Box
+ case "tasks":
+ return Activity
case "todo":
return ListTodo
case "plan":
@@ -65,6 +69,8 @@ function getWidgetIcon(widgetId: WidgetId) {
return DiffIcon
case "mcp":
return OriginalMCPIcon
+ case "pr":
+ return GitPullRequest
default:
return Box
}
@@ -322,7 +328,13 @@ export function DetailsSidebar({
>
{/* Header with pill tabs */}
-
+
@@ -433,6 +445,11 @@ export function DetailsSidebar({
)
+ case "tasks":
+ return (
+
+ )
+
case "todo":
return (
@@ -495,6 +512,15 @@ export function DetailsSidebar({
/>
)
+ case "pr":
+ // Only show for local chats with a worktree
+ if (!worktreePath) return null
+ return (
+
+
+
+ )
+
case "mcp":
return (
{/* Header */}
-
+
{widgetConfig && (
<>
diff --git a/src/renderer/features/details-sidebar/sections/changes-widget.tsx b/src/renderer/features/details-sidebar/sections/changes-widget.tsx
index c42fdcbe8..aea48bb4d 100644
--- a/src/renderer/features/details-sidebar/sections/changes-widget.tsx
+++ b/src/renderer/features/details-sidebar/sections/changes-widget.tsx
@@ -25,6 +25,7 @@ import { trpc } from "@/lib/trpc"
import { preferredEditorAtom } from "@/lib/atoms"
import { APP_META } from "../../../../shared/external-apps"
import type { ParsedDiffFile } from "../types"
+import { BranchSwitcherPopover } from "@/features/changes/components/branch-switcher/branch-switcher-popover"
interface ChangesWidgetProps {
chatId: string
@@ -263,14 +264,23 @@ export const ChangesWidget = memo(function ChangesWidget({
{/* Title + branch */}
Changes
- {currentBranch && (
+ {currentBranch && worktreePath ? (
+
+ on
+
+
+ ) : currentBranch ? (
on
{currentBranch}
- )}
+ ) : null}
{/* Stats in header - total lines changed */}
diff --git a/src/renderer/features/details-sidebar/sections/info-section.tsx b/src/renderer/features/details-sidebar/sections/info-section.tsx
index 240992d5c..0fd74c30d 100644
--- a/src/renderer/features/details-sidebar/sections/info-section.tsx
+++ b/src/renderer/features/details-sidebar/sections/info-section.tsx
@@ -2,12 +2,14 @@
import { memo, useState, useCallback, useEffect } from "react"
import { useAtomValue } from "jotai"
+import { Pencil } from "lucide-react"
import {
GitBranchFilledIcon,
FolderFilledIcon,
GitPullRequestFilledIcon,
ExternalLinkIcon,
} from "@/components/ui/icons"
+import { RenamePrTitleDialog } from "./rename-pr-title-dialog"
import { Kbd } from "@/components/ui/kbd"
import {
Tooltip,
@@ -19,6 +21,7 @@ import { preferredEditorAtom } from "@/lib/atoms"
import { useResolvedHotkeyDisplay } from "@/lib/hotkeys"
import { APP_META } from "../../../../shared/external-apps"
import { EDITOR_ICONS } from "@/lib/editor-icons"
+import { toast } from "sonner"
interface InfoSectionProps {
chatId: string
@@ -41,6 +44,7 @@ function PropertyRow({
onClick,
copyable,
tooltip,
+ badge,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
@@ -50,6 +54,8 @@ function PropertyRow({
copyable?: boolean
/** Tooltip to show on hover (for clickable items) */
tooltip?: string
+ /** Optional trailing element rendered next to the value (e.g. branch pill on PR row) */
+ badge?: React.ReactNode
}) {
const [showCopied, setShowCopied] = useState(false)
@@ -80,6 +86,28 @@ function PropertyRow({
)
+ const wrappedValue = copyable ? (
+
+
+ {valueEl}
+
+
+ {showCopied ? "Copied" : "Click to copy"}
+
+
+ ) : tooltip ? (
+
+
+ {valueEl}
+
+
+ {tooltip}
+
+
+ ) : (
+ valueEl
+ )
+
return (
{/* Label column - fixed width */}
@@ -88,28 +116,9 @@ function PropertyRow({
{label}
{/* Value column - flexible */}
-
- {copyable ? (
-
-
- {valueEl}
-
-
- {showCopied ? "Copied" : "Click to copy"}
-
-
- ) : tooltip ? (
-
-
- {valueEl}
-
-
- {tooltip}
-
-
- ) : (
- valueEl
- )}
+
+
{wrappedValue}
+ {badge}
)
@@ -129,13 +138,22 @@ export const InfoSection = memo(function InfoSection({
// Extract folder name from path
const folderName = worktreePath?.split("/").pop() || "Unknown"
+ const [isRenamePrOpen, setIsRenamePrOpen] = useState(false)
+
// Preferred editor from settings
const preferredEditor = useAtomValue(preferredEditorAtom)
const editorMeta = APP_META[preferredEditor]
// Mutations
const openInFinderMutation = trpc.external.openInFinder.useMutation()
- const openInAppMutation = trpc.external.openInApp.useMutation()
+ const openInAppMutation = trpc.external.openInApp.useMutation({
+ onError: (error, vars) => {
+ const appLabel = APP_META[vars.app]?.label ?? vars.app
+ toast.error(`Couldn't open ${appLabel}`, {
+ description: error.message || "Make sure the app is installed and its CLI is on your PATH.",
+ })
+ },
+ })
// Check if this is a remote sandbox chat (no local worktree)
const isRemoteChat = !worktreePath && !!remoteInfo
@@ -171,7 +189,10 @@ export const InfoSection = memo(function InfoSection({
}
}
- const isWorktree = !!worktreePath && worktreePath.includes(".21st/worktrees")
+ // Show the "Open in editor" row for any local chat with a repo path,
+ // whether that path is a worktree (~/.21st/worktrees/...) or the project
+ // folder itself (project-mode chats).
+ const canOpenInEditor = !!worktreePath
const openInEditorHotkey = useResolvedHotkeyDisplay("open-in-editor")
const handleOpenInEditor = useCallback(() => {
@@ -182,11 +203,11 @@ export const InfoSection = memo(function InfoSection({
// Listen for ⌘O hotkey event
useEffect(() => {
- if (!isWorktree) return
+ if (!canOpenInEditor) return
const handler = () => handleOpenInEditor()
window.addEventListener("open-in-editor", handler)
return () => window.removeEventListener("open-in-editor", handler)
- }, [isWorktree, handleOpenInEditor])
+ }, [canOpenInEditor, handleOpenInEditor])
const handleOpenPr = () => {
if (pr?.url) {
@@ -274,6 +295,49 @@ export const InfoSection = memo(function InfoSection({
title={pr.title}
onClick={handleOpenPr}
tooltip="Open in GitHub"
+ badge={
+
+ {branchName && (
+
+
+
+
+ {branchName}
+
+
+
+ PR branch: {branchName}
+
+
+ )}
+
+
+
+
+
+ Rename PR title
+
+
+
+ }
+ />
+ )}
+ {pr && (
+
)}
{/* Path - only for local chats */}
@@ -287,8 +351,8 @@ export const InfoSection = memo(function InfoSection({
tooltip="Open in Finder"
/>
)}
- {/* Open in Editor - only for actual git worktrees (under ~/.21st/worktrees/) */}
- {isWorktree && (
+ {/* Open in Editor — any local chat with a repo path (project or worktree) */}
+ {canOpenInEditor && (
diff --git a/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx
new file mode 100644
index 000000000..068a0f531
--- /dev/null
+++ b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import { trpc } from "@/lib/trpc"
+import { IconSpinner } from "@/components/ui/icons"
+import { Button } from "@/components/ui/button"
+import { Copy } from "lucide-react"
+import { toast } from "sonner"
+
+interface PrCommentsListProps {
+ chatId: string
+}
+
+function relativeTime(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime()
+ const minutes = Math.floor(diff / 60_000)
+ if (minutes < 1) return "just now"
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ if (days < 30) return `${days}d ago`
+ return new Date(iso).toLocaleDateString()
+}
+
+function buildCopyText(c: {
+ author: string
+ createdAt: string
+ body: string
+ path?: string | null
+ diffHunk?: string | null
+}): string {
+ const header = `${c.author} · ${new Date(c.createdAt).toLocaleString()}${
+ c.path ? `\n${c.path}` : ""
+ }`
+ const hunk = c.diffHunk ? `\n\n${c.diffHunk}` : ""
+ return `${header}\n\n${c.body}${hunk}`
+}
+
+export function PrCommentsList({ chatId }: PrCommentsListProps) {
+ const { data, isLoading, isError, error } = trpc.chats.getPrComments.useQuery(
+ { chatId },
+ { refetchInterval: 60_000, enabled: !!chatId },
+ )
+
+ if (isLoading) {
+ return (
+
+
+ Loading comments…
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+ Couldn't load comments: {error?.message}
+
+ )
+ }
+
+ const comments = data ?? []
+ if (comments.length === 0) {
+ return (
+
+ No comments yet.
+
+ )
+ }
+
+ const copyAll = async () => {
+ const text = comments.map(buildCopyText).join("\n\n---\n\n")
+ await navigator.clipboard.writeText(text)
+ toast.success(`Copied ${comments.length} comment${comments.length === 1 ? "" : "s"}`)
+ }
+
+ const copyOne = async (c: (typeof comments)[number]) => {
+ await navigator.clipboard.writeText(buildCopyText(c))
+ toast.success("Comment copied")
+ }
+
+ return (
+
+
+
+ {comments.length} comment{comments.length === 1 ? "" : "s"}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/features/details-sidebar/sections/pr-widget.tsx b/src/renderer/features/details-sidebar/sections/pr-widget.tsx
new file mode 100644
index 000000000..2c6f94580
--- /dev/null
+++ b/src/renderer/features/details-sidebar/sections/pr-widget.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import { memo, useState } from "react"
+import {
+ Check,
+ CircleDashed,
+ ExternalLink,
+ MessageSquare,
+ TriangleAlert,
+ X,
+} from "lucide-react"
+import { trpc } from "@/lib/trpc"
+import { cn } from "@/lib/utils"
+import { IconSpinner } from "@/components/ui/icons"
+import { Button } from "@/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PRIcon } from "@/features/changes/components/pr-icon"
+import { RenamePrTitleDialog } from "./rename-pr-title-dialog"
+import { PrCommentsList } from "./pr-comments-section"
+
+interface PrWidgetProps {
+ chatId: string
+}
+
+type ReviewDecision = "approved" | "changes_requested" | "pending"
+
+function reviewLabel(decision?: ReviewDecision | null): string | null {
+ if (!decision) return null
+ if (decision === "approved") return "Approved"
+ if (decision === "changes_requested") return "Changes requested"
+ return "Review pending"
+}
+
+function reviewTone(decision?: ReviewDecision | null): string {
+ if (decision === "approved") return "text-emerald-600 dark:text-emerald-400"
+ if (decision === "changes_requested") return "text-amber-600 dark:text-amber-400"
+ return "text-muted-foreground"
+}
+
+function stateLabel(state: string, isDraft?: boolean): string {
+ if (state === "merged") return "Merged"
+ if (state === "closed") return "Closed"
+ if (isDraft || state === "draft") return "Draft"
+ return "Open"
+}
+
+export const PrWidget = memo(function PrWidget({ chatId }: PrWidgetProps) {
+ const { data: status, isLoading } = trpc.chats.getPrStatus.useQuery(
+ { chatId },
+ { refetchInterval: 30000, enabled: !!chatId },
+ )
+
+ const [isRenameOpen, setIsRenameOpen] = useState(false)
+ const [showComments, setShowComments] = useState(false)
+
+ if (isLoading && !status) {
+ return (
+
+
+ Loading PR status…
+
+ )
+ }
+
+ const pr = status?.pr
+ if (!pr) {
+ return (
+
+ No pull request for this branch yet.
+
+ )
+ }
+
+ const openPr = () => {
+ window.desktopApi.openExternal(pr.url)
+ }
+
+ const checks = pr.checks ?? []
+ const successCount = checks.filter((c) => c.status === "success").length
+ const failureCount = checks.filter((c) => c.status === "failure").length
+ const pendingCount = checks.filter((c) => c.status === "pending").length
+
+ return (
+
+
+ {/* Title row */}
+
+
+
+
+ #{pr.number}
+ ·
+ {stateLabel(pr.state)}
+
+
+
+
+
+
+
+
+ Open pull request
+
+
+
+
+ {/* Review + checks row */}
+
+ {reviewLabel(pr.reviewDecision) && (
+
+ {pr.reviewDecision === "approved" ? (
+
+ ) : pr.reviewDecision === "changes_requested" ? (
+
+ ) : (
+
+ )}
+ {reviewLabel(pr.reviewDecision)}
+
+ )}
+ {checks.length > 0 && (
+
+ {successCount > 0 && (
+
+
+ {successCount}
+
+ )}
+ {failureCount > 0 && (
+
+
+ {failureCount}
+
+ )}
+ {pendingCount > 0 && (
+
+
+ {pendingCount}
+
+ )}
+
+ )}
+ {(pr.additions !== undefined || pr.deletions !== undefined) && (
+
+
+ +{pr.additions ?? 0}
+ {" "}
+
+ −{pr.deletions ?? 0}
+
+
+ )}
+
+
+ {/* Comments toggle */}
+
+
+
+ {showComments &&
}
+
+
+
+ )
+})
diff --git a/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx
new file mode 100644
index 000000000..a41ec534d
--- /dev/null
+++ b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { trpc } from "@/lib/trpc"
+
+interface RenamePrTitleDialogProps {
+ chatId: string
+ open: boolean
+ initialTitle: string
+ prNumber: number
+ onOpenChange: (open: boolean) => void
+}
+
+export function RenamePrTitleDialog({
+ chatId,
+ open,
+ initialTitle,
+ prNumber,
+ onOpenChange,
+}: RenamePrTitleDialogProps) {
+ const [title, setTitle] = useState(initialTitle)
+ const utils = trpc.useUtils()
+
+ useEffect(() => {
+ if (open) setTitle(initialTitle)
+ }, [open, initialTitle])
+
+ const mutation = trpc.chats.updatePrTitle.useMutation({
+ onSuccess: () => {
+ utils.chats.getPrStatus.invalidate({ chatId })
+ toast.success(`Renamed PR #${prNumber}`)
+ onOpenChange(false)
+ },
+ onError: (error) => {
+ toast.error("Couldn't rename PR", { description: error.message })
+ },
+ })
+
+ const trimmed = title.trim()
+ const canSave =
+ trimmed.length > 0 && trimmed !== initialTitle.trim() && !mutation.isPending
+
+ const handleSave = () => {
+ if (!canSave) return
+ mutation.mutate({ chatId, title: trimmed, prNumber })
+ }
+
+ return (
+
+ )
+}
diff --git a/src/renderer/features/details-sidebar/sections/tasks-widget.tsx b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx
new file mode 100644
index 000000000..170df242a
--- /dev/null
+++ b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx
@@ -0,0 +1,258 @@
+"use client"
+
+import { memo, useEffect, useMemo, useRef, useState } from "react"
+import { atom, useAtomValue } from "jotai"
+import { atomFamily } from "jotai/utils"
+import { Activity, Loader2 } from "lucide-react"
+import { cn } from "@/lib/utils"
+import {
+ getPerChatMessageKey,
+ messageAtomFamily,
+ messageIdsPerChatAtom,
+ type Message,
+} from "@/features/agents/stores/message-store"
+import { useStreamingStatusStore } from "@/features/agents/stores/streaming-status-store"
+
+interface TasksWidgetProps {
+ subChatId: string | null
+}
+
+interface RunningTask {
+ toolCallId: string
+ toolName: string
+ summary: string
+ startedAt: number
+ parentId: string | null
+ children: RunningTask[]
+}
+
+// Tools that are tracked elsewhere (Todo widget / plan approvals) or are not real work.
+const EXCLUDED_TOOL_NAMES = new Set([
+ "TodoWrite",
+ "TaskCreate",
+ "TaskUpdate",
+ "TaskList",
+ "TaskGet",
+ "TaskOutput",
+ "ExitPlanMode",
+ "Thinking",
+])
+
+function summarizeInput(input: unknown): string {
+ if (!input || typeof input !== "object") return ""
+ const rec = input as Record
+ const preferredKeys = [
+ "command",
+ "file_path",
+ "path",
+ "pattern",
+ "description",
+ "url",
+ "query",
+ "prompt",
+ "subagent_type",
+ ]
+ for (const key of preferredKeys) {
+ const v = rec[key]
+ if (typeof v === "string" && v.length > 0) return v
+ }
+ for (const key in rec) {
+ const v = rec[key]
+ if (typeof v === "string" && v.length > 0) return v
+ }
+ return ""
+}
+
+function formatElapsed(ms: number): string {
+ const sec = Math.max(0, Math.floor(ms / 1000))
+ if (sec < 60) return `${sec}s`
+ const min = Math.floor(sec / 60)
+ const rem = sec % 60
+ return `${min}m ${rem.toString().padStart(2, "0")}s`
+}
+
+// Derived atom: the last assistant Message for a given subChatId.
+// Scans from the end of messageIdsPerChatAtom; returns null if none found.
+const lastAssistantMessageForSubChatAtomFamily = atomFamily((subChatId: string) =>
+ atom((get) => {
+ const ids = get(messageIdsPerChatAtom(subChatId))
+ for (let i = ids.length - 1; i >= 0; i--) {
+ const id = ids[i]
+ if (!id) continue
+ const msg = get(messageAtomFamily(getPerChatMessageKey(subChatId, id)))
+ if (msg && msg.role === "assistant") return msg
+ }
+ return null
+ }),
+)
+
+export const TasksWidget = memo(function TasksWidget({
+ subChatId,
+}: TasksWidgetProps) {
+ const key = subChatId || "default"
+
+ const isStreaming = useStreamingStatusStore((s) => s.isStreaming(key))
+
+ const lastAssistantAtom = useMemo(
+ () => lastAssistantMessageForSubChatAtomFamily(key),
+ [key],
+ )
+ const lastAssistant = useAtomValue(lastAssistantAtom)
+
+ const startedAtRef = useRef