feat: add command output capture to troubleshooting sessions (#57)

Engineers can now paste command output during action steps. Output is
stored in the session decisions JSONB, displayed in session review,
included in all 4 export formats with command context, and preserved
in session-to-tree conversions.

- Collapsible "Paste Output" textarea on action nodes with commands
- 10,000 character limit with live character count
- Works on both built-in and custom action steps
- Preloads output when revisiting a step via Go Back
- All exports show commands run alongside captured output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-12 00:57:33 -05:00
parent 9dbb8b8406
commit 577a2fbf2a
7 changed files with 413 additions and 3 deletions

View File

@@ -224,6 +224,7 @@ export function SessionDetailPage() {
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
try {
await navigator.clipboard.writeText(lines.join('\n'))
setCopiedStepIndex(index)
@@ -439,6 +440,14 @@ export function SessionDetailPage() {
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
{decision.command_output}
</pre>
</div>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}

View File

@@ -10,7 +10,7 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
import { Plus, CheckCircle, ArrowRight, Clock, Terminal } from 'lucide-react'
interface LocationState {
sessionId?: string
@@ -33,6 +33,8 @@ export function TreeNavigationPage() {
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
const [notes, setNotes] = useState<string>('')
const [commandOutput, setCommandOutput] = useState<string>('')
const [commandOutputOpen, setCommandOutputOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isCompleting, setIsCompleting] = useState(false)
@@ -122,6 +124,35 @@ export function TreeNavigationPage() {
},
})
// Inject command_output into the last decision (for custom steps) before continuing
const updateLastDecisionWithCommandOutput = async () => {
const output = commandOutput.trim() || null
if (!output || !session || decisions.length === 0) return
const updatedDecisions = [...decisions]
updatedDecisions[updatedDecisions.length - 1] = {
...updatedDecisions[updatedDecisions.length - 1],
command_output: output,
}
setDecisions(updatedDecisions)
try {
await sessionsApi.update(session.id, { decisions: updatedDecisions })
} catch (err) {
console.error('Failed to update decision with command output:', err)
}
}
const handleCustomContinueToDescendant = async () => {
await updateLastDecisionWithCommandOutput()
setCommandOutput('')
setCommandOutputOpen(false)
customStepFlow.handleContinueToDescendant()
}
const handleCustomBranchCompleteWithOutput = async () => {
await updateLastDecisionWithCommandOutput()
customStepFlow.handleCustomBranchComplete()
}
const handleScratchpadSave = async (content: string) => {
if (!session) return
await sessionsApi.updateScratchpad(session.id, content)
@@ -218,6 +249,8 @@ export function TreeNavigationPage() {
setCurrentNodeId(nextNodeId)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
setCommandOutput('')
setCommandOutputOpen(false)
try {
await sessionsApi.update(session.id, {
@@ -243,6 +276,7 @@ export function TreeNavigationPage() {
answer: null,
action_performed: actionPerformed || node.title || 'Action completed',
notes: notes || null,
command_output: commandOutput.trim() || null,
automation_used: false,
timestamp: exitedAt,
entered_at: enteredAt,
@@ -259,6 +293,8 @@ export function TreeNavigationPage() {
setCurrentNodeId(node.next_node_id)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
setCommandOutput('')
setCommandOutputOpen(false)
try {
await sessionsApi.update(session.id, {
@@ -280,6 +316,7 @@ export function TreeNavigationPage() {
answer: null,
action_performed: node.title || 'Session completed',
notes: notes || null,
command_output: commandOutput.trim() || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
@@ -321,11 +358,16 @@ export function TreeNavigationPage() {
const handleGoBack = () => {
if (pathTaken.length <= 1) return
const newPath = pathTaken.slice(0, -1)
const removedDecision = decisions[decisions.length - 1]
const newDecisions = decisions.slice(0, -1)
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(newPath[newPath.length - 1])
setCurrentStepEnteredAt(new Date().toISOString())
// Preload fields from the removed decision when revisiting
const prevOutput = removedDecision?.command_output || ''
setCommandOutput(prevOutput)
setCommandOutputOpen(!!prevOutput)
}
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
@@ -614,6 +656,37 @@ export function TreeNavigationPage() {
</div>
))}
</div>
{/* Command Output Capture */}
<div className="mt-3">
<button
type="button"
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
>
<Terminal className="h-3.5 w-3.5" />
<span>Paste Output (Optional)</span>
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
</button>
{commandOutputOpen && (
<div className="mt-2">
<textarea
value={commandOutput}
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
placeholder="Paste command output here..."
rows={4}
maxLength={10000}
className={cn(
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
'font-mono text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
<p className="mt-1 text-right text-xs text-white/30">
{commandOutput.length.toLocaleString()} / 10,000
</p>
</div>
)}
</div>
</div>
)}
@@ -625,7 +698,7 @@ export function TreeNavigationPage() {
<div className="mt-6 border-t border-purple-700 pt-4">
<button
type="button"
onClick={customStepFlow.handleContinueToDescendant}
onClick={handleCustomContinueToDescendant}
className={cn(
'flex w-full items-center justify-between rounded-md bg-white px-4 py-3 text-sm font-medium text-black',
'hover:bg-white/90'
@@ -656,7 +729,7 @@ export function TreeNavigationPage() {
Add Another Step
</button>
<button
onClick={customStepFlow.handleCustomBranchComplete}
onClick={handleCustomBranchCompleteWithOutput}
disabled={isCompleting}
className={cn(
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
@@ -696,6 +769,37 @@ export function TreeNavigationPage() {
</code>
))}
</div>
{/* Command Output Capture */}
<div className="mt-3">
<button
type="button"
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
>
<Terminal className="h-3.5 w-3.5" />
<span>Paste Output (Optional)</span>
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
</button>
{commandOutputOpen && (
<div className="mt-2">
<textarea
value={commandOutput}
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
placeholder="Paste command output here..."
rows={4}
maxLength={10000}
className={cn(
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
'font-mono text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
<p className="mt-1 text-right text-xs text-white/30">
{commandOutput.length.toLocaleString()} / 10,000
</p>
</div>
)}
</div>
</div>
)}
{currentNode.expected_outcome && (

View File

@@ -9,6 +9,7 @@ export interface DecisionRecord {
answer: string | null
action_performed: string | null
notes: string | null
command_output?: string | null
automation_used: boolean
timestamp: string
entered_at?: string | null