Просмотр исходного кода

New panel with all TODOs in session

paviko 4 месяцев назад
Родитель
Сommit
0164981a94

+ 1 - 0
hosts/jetbrains-plugin/changelog.html

@@ -4,6 +4,7 @@
 <ul>
   <li>Auto refresh files in IDE on edit/write</li>
   <li>New panel with all modified files in session</li>
+  <li>New panel with all TODOs in session</li>
   <li>Fixed placement of Model/Agent selector</li>
 </ul>
 

+ 1 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Auto refresh files in IDE on edit/write
 - New panel with all modified files in session
+- New panel with all TODOs in session
 - Fixed placement of Model/Agent selector
 
 ## [25.11.30] - 2025-11-30

+ 90 - 0
packages/opencode/webgui/src/components/MessageInput/FooterPanels.tsx

@@ -0,0 +1,90 @@
+import { useState, useMemo } from "react"
+import { useMessages } from "../../state/MessagesContext"
+import { ModifiedFilesList } from "./ModifiedFilesPanel"
+import { TodosList } from "./TodosPanel"
+
+interface FooterPanelsProps {
+  sessionID: string | null
+}
+
+export function FooterPanels({ sessionID }: FooterPanelsProps) {
+  const [filesExpanded, setFilesExpanded] = useState(false)
+  const [todosExpanded, setTodosExpanded] = useState(false)
+  const { getMessagesBySession } = useMessages()
+
+  const { modifiedFiles, todos } = useMemo(() => {
+    if (!sessionID) return { modifiedFiles: [] as string[], todos: null }
+    const messages = getMessagesBySession(sessionID)
+    const files: string[] = []
+    const seen = new Set<string>()
+    let todoOutput: string | null = null
+
+    for (const msg of messages) {
+      for (const part of msg.parts) {
+        if (part.type !== "tool") continue
+        const toolPart = part as { tool?: string; state?: { input?: { filePath?: string }; output?: string } }
+        if ((toolPart.tool === "write" || toolPart.tool === "edit") && toolPart.state?.input?.filePath) {
+          const path = toolPart.state.input.filePath
+          if (!seen.has(path)) {
+            seen.add(path)
+            files.push(path)
+          }
+        }
+        if (toolPart.tool === "todowrite" && toolPart.state?.output) {
+          todoOutput = toolPart.state.output
+        }
+      }
+    }
+
+    let todos = null
+    if (todoOutput) {
+      try {
+        const parsed = JSON.parse(todoOutput)
+        if (Array.isArray(parsed)) todos = parsed
+      } catch {}
+    }
+
+    return { modifiedFiles: files, todos }
+  }, [sessionID, getMessagesBySession])
+
+  const hasFiles = modifiedFiles.length > 0
+  const hasTodos = todos && todos.length > 0
+
+  if (!hasFiles && !hasTodos) return null
+
+  const completedTodos = todos?.filter((t: any) => t.status === "completed").length ?? 0
+
+  return (
+    <div className="px-2 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 flex flex-col gap-1">
+      {/* Expanded content - TODOs above files */}
+      {todosExpanded && hasTodos && <TodosList todos={todos} />}
+      {filesExpanded && hasFiles && <ModifiedFilesList files={modifiedFiles} />}
+
+      {/* Labels row */}
+      <div className="flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
+        {hasFiles && (
+          <button
+            onClick={() => setFilesExpanded(!filesExpanded)}
+            className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-200"
+          >
+            <svg className={`w-3 h-3 transition-transform ${filesExpanded ? "-rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+            </svg>
+            <span>{modifiedFiles.length} file{modifiedFiles.length !== 1 ? "s" : ""} changed</span>
+          </button>
+        )}
+        {hasTodos && (
+          <button
+            onClick={() => setTodosExpanded(!todosExpanded)}
+            className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-200"
+          >
+            <svg className={`w-3 h-3 transition-transform ${todosExpanded ? "-rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+            </svg>
+            <span>{completedTodos}/{todos.length} TODOs</span>
+          </button>
+        )}
+      </div>
+    </div>
+  )
+}

+ 18 - 65
packages/opencode/webgui/src/components/MessageInput/ModifiedFilesPanel.tsx

@@ -1,37 +1,14 @@
-import { useState, useMemo, useCallback, type KeyboardEvent } from "react"
-import { useMessages } from "../../state/MessagesContext"
+import { useCallback, type KeyboardEvent } from "react"
 import { useOpenFile } from "../../hooks/useOpenFile"
+import { normalizePath } from "../../utils/path"
 
-interface ModifiedFilesPanelProps {
-  sessionID: string | null
+interface ModifiedFilesListProps {
+  files: string[]
 }
 
-export function ModifiedFilesPanel({ sessionID }: ModifiedFilesPanelProps) {
-  const [expanded, setExpanded] = useState(false)
-  const { getMessagesBySession } = useMessages()
+export function ModifiedFilesList({ files }: ModifiedFilesListProps) {
   const openFile = useOpenFile()
 
-  const modifiedFiles = useMemo(() => {
-    if (!sessionID) return []
-    const messages = getMessagesBySession(sessionID)
-    const files: string[] = []
-    const seen = new Set<string>()
-
-    for (const msg of messages) {
-      for (const part of msg.parts) {
-        if (part.type !== "tool") continue
-        const toolPart = part as { tool?: string; state?: { input?: { filePath?: string } } }
-        if (toolPart.tool !== "write" && toolPart.tool !== "edit") continue
-        const path = toolPart.state?.input?.filePath
-        if (path && !seen.has(path)) {
-          seen.add(path)
-          files.push(path)
-        }
-      }
-    }
-    return files
-  }, [sessionID, getMessagesBySession])
-
   const handleFileClick = useCallback(
     (path: string) => {
       openFile({ path })
@@ -49,45 +26,21 @@ export function ModifiedFilesPanel({ sessionID }: ModifiedFilesPanelProps) {
     [handleFileClick],
   )
 
-  if (modifiedFiles.length === 0) return null
-
   return (
-    <div className="px-2 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50">
-      <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
-        <button
-          onClick={() => setExpanded(!expanded)}
-          className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-200"
+    <div className="flex flex-wrap gap-1">
+      {files.map((path) => (
+        <span
+          key={path}
+          role="button"
+          tabIndex={0}
+          onClick={() => handleFileClick(path)}
+          onKeyDown={handleKeyDown(path)}
+          className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
+          title={path}
         >
-          <svg
-            className={`w-3 h-3 transition-transform ${expanded ? "rotate-90" : ""}`}
-            fill="none"
-            stroke="currentColor"
-            viewBox="0 0 24 24"
-          >
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
-          </svg>
-          <span>
-            {modifiedFiles.length} file{modifiedFiles.length !== 1 ? "s" : ""} changed
-          </span>
-        </button>
-      </div>
-      {expanded && (
-        <div className="mt-1 flex flex-wrap gap-1">
-          {modifiedFiles.map((path) => (
-            <span
-              key={path}
-              role="button"
-              tabIndex={0}
-              onClick={() => handleFileClick(path)}
-              onKeyDown={handleKeyDown(path)}
-              className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
-              title={path}
-            >
-              {path.split("/").pop()}
-            </span>
-          ))}
-        </div>
-      )}
+          {normalizePath(path).split("/").pop()}
+        </span>
+      ))}
     </div>
   )
 }

+ 46 - 0
packages/opencode/webgui/src/components/MessageInput/TodosPanel.tsx

@@ -0,0 +1,46 @@
+import type { Todo } from "../parts/ToolPart/utils"
+
+interface TodosListProps {
+  todos: Todo[]
+}
+
+export function TodosList({ todos }: TodosListProps) {
+  return (
+    <div className="space-y-1">
+      {todos.map((todo, index) => (
+        <div
+          key={todo.id || index}
+          className="flex items-center gap-2 px-2 py-1 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
+        >
+          <div className="flex-shrink-0">{getStatusIcon(todo.status)}</div>
+          <p className={`text-xs flex-1 truncate ${todo.status === "completed" ? "line-through text-gray-500" : "text-gray-700 dark:text-gray-300"}`}>
+            {todo.content}
+          </p>
+          {todo.priority && <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${getPriorityColors(todo.priority)}`}>{todo.priority}</span>}
+        </div>
+      ))}
+    </div>
+  )
+}
+
+function getStatusIcon(status: string) {
+  switch (status) {
+    case "completed":
+      return <svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+    case "in_progress":
+      return <svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+    case "cancelled":
+      return <svg className="w-4 h-4 text-gray-400 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+    default:
+      return <svg className="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" strokeWidth={2} /></svg>
+  }
+}
+
+function getPriorityColors(priority: string) {
+  const colors: Record<string, string> = {
+    high: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300",
+    medium: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300",
+    low: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
+  }
+  return colors[priority] || colors.low
+}

+ 2 - 2
packages/opencode/webgui/src/components/MessageInput/index.tsx

@@ -11,7 +11,7 @@ import { ConfirmModal } from "../ConfirmModal"
 import { createEditorConfig } from "./EditorConfig"
 import { EditorContent } from "./EditorContent"
 import { EditorToolbar } from "./EditorToolbar"
-import { ModifiedFilesPanel } from "./ModifiedFilesPanel"
+import { FooterPanels } from "./FooterPanels"
 import { useMessageInput } from "./hooks/useMessageInput"
 import { useFileAttachment } from "./hooks/useFileAttachment"
 import { useDragDrop } from "./hooks/useDragDrop"
@@ -250,7 +250,7 @@ const MessageInputInner = forwardRef<
   return (
     <>
       <footer className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex-shrink-0">
-        <ModifiedFilesPanel sessionID={sessionID} />
+        <FooterPanels sessionID={sessionID} />
         <EditorContent
           contentEditableRef={contentEditableRef}
           containerRef={containerRef}