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:
@@ -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)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user