Merge pull request #74 from patherly/feat/command-output-capture
feat: command output capture for troubleshooting sessions
This commit was merged in pull request #74.
This commit is contained in:
@@ -133,6 +133,10 @@ def _create_node_from_original(
|
|||||||
new_node["action"] = original_node.get("action", "")
|
new_node["action"] = original_node.get("action", "")
|
||||||
if decision and decision.get("action_performed"):
|
if decision and decision.get("action_performed"):
|
||||||
new_node["action"] = decision["action_performed"]
|
new_node["action"] = decision["action_performed"]
|
||||||
|
if decision and decision.get("command_output"):
|
||||||
|
output = decision["command_output"].strip()
|
||||||
|
if output:
|
||||||
|
new_node["action"] += f"\n\nCommand Output:\n{output}"
|
||||||
elif node_type == "solution":
|
elif node_type == "solution":
|
||||||
new_node["solution"] = original_node.get("solution", "")
|
new_node["solution"] = original_node.get("solution", "")
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class DecisionRecord(BaseModel):
|
|||||||
answer: Optional[str] = None
|
answer: Optional[str] = None
|
||||||
action_performed: Optional[str] = None
|
action_performed: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
command_output: Optional[str] = Field(None, max_length=10000)
|
||||||
automation_used: Optional[bool] = False
|
automation_used: Optional[bool] = False
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
entered_at: Optional[datetime] = None
|
entered_at: Optional[datetime] = None
|
||||||
|
|||||||
@@ -81,6 +81,30 @@ def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_command_output(decision: dict[str, Any]) -> str | None:
|
||||||
|
"""Extract and normalize command_output from a decision dict."""
|
||||||
|
output = decision.get("command_output")
|
||||||
|
if not isinstance(output, str):
|
||||||
|
return None
|
||||||
|
output = output.strip()
|
||||||
|
return output if output else None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]:
|
||||||
|
"""Find the commands list for a node in the tree snapshot."""
|
||||||
|
def _search(node: dict[str, Any]) -> list[str] | None:
|
||||||
|
if node.get("id") == node_id:
|
||||||
|
return node.get("commands") or []
|
||||||
|
for child in node.get("children", []):
|
||||||
|
result = _search(child)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
structure = tree_snapshot.get("tree_structure") or tree_snapshot
|
||||||
|
return _search(structure) or []
|
||||||
|
|
||||||
|
|
||||||
def _get_outcome_label(session: Session) -> str | None:
|
def _get_outcome_label(session: Session) -> str | None:
|
||||||
"""Map stored outcome enum to human-friendly label."""
|
"""Map stored outcome enum to human-friendly label."""
|
||||||
outcome = getattr(session, "outcome", None)
|
outcome = getattr(session, "outcome", None)
|
||||||
@@ -137,6 +161,14 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(f"**Answer:** {answer}")
|
lines.append(f"**Answer:** {answer}")
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f"**Notes:** {notes}")
|
lines.append(f"**Notes:** {notes}")
|
||||||
|
if command_output := _get_command_output(decision):
|
||||||
|
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||||
|
if commands:
|
||||||
|
lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}")
|
||||||
|
lines.append("**Output:**")
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(command_output)
|
||||||
|
lines.append("```")
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
||||||
if options.include_timestamps and decision.get("timestamp"):
|
if options.include_timestamps and decision.get("timestamp"):
|
||||||
@@ -190,6 +222,13 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(f" Answer: {answer}")
|
lines.append(f" Answer: {answer}")
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f" Notes: {notes}")
|
lines.append(f" Notes: {notes}")
|
||||||
|
if command_output := _get_command_output(decision):
|
||||||
|
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||||
|
if commands:
|
||||||
|
lines.append(f" Commands Run: {', '.join(commands)}")
|
||||||
|
lines.append(" Output:")
|
||||||
|
for output_line in command_output.splitlines():
|
||||||
|
lines.append(f" {output_line}")
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||||
|
|
||||||
@@ -253,6 +292,12 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||||
if notes:
|
if notes:
|
||||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||||
|
if command_output := _get_command_output(decision):
|
||||||
|
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||||
|
if commands:
|
||||||
|
cmd_html = ", ".join(f"<code>{html.escape(c)}</code>" for c in commands)
|
||||||
|
html_parts.append(f'<p><strong>Commands Run:</strong> {cmd_html}</p>')
|
||||||
|
html_parts.append(f'<pre><code>{html.escape(command_output)}</code></pre>')
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
||||||
if options.include_timestamps and decision.get("timestamp"):
|
if options.include_timestamps and decision.get("timestamp"):
|
||||||
@@ -304,6 +349,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(line)
|
lines.append(line)
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f" Notes: {notes}")
|
lines.append(f" Notes: {notes}")
|
||||||
|
if command_output := _get_command_output(decision):
|
||||||
|
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||||
|
if commands:
|
||||||
|
lines.append(f" Commands: {', '.join(commands)}")
|
||||||
|
lines.append(" Output:")
|
||||||
|
for output_line in command_output.splitlines():
|
||||||
|
lines.append(f" {output_line}")
|
||||||
else:
|
else:
|
||||||
lines.append("No steps recorded.")
|
lines.append("No steps recorded.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
239
docs/plans/2026-02-12-issue-57-command-output-capture.md
Normal file
239
docs/plans/2026-02-12-issue-57-command-output-capture.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Issue #57: Command Output Capture — Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Engineers run commands during troubleshooting sessions but the output is lost — exports say "ran this command" but not what it returned. This feature adds a "Paste Output" textarea on action nodes and custom action steps so command output is captured in session data and included in all exports and session review.
|
||||||
|
|
||||||
|
**Scope:** Built-in action nodes AND custom action steps.
|
||||||
|
**Migration:** None required — `decisions` is already a JSONB array with flexible dict entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Interfaces / Type Changes
|
||||||
|
|
||||||
|
- **Backend:** Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord` in `session.py`
|
||||||
|
- **Frontend:** Add `command_output?: string | null` to `DecisionRecord` type in `session.ts`
|
||||||
|
- **API:** No endpoint changes — `PUT /api/v1/sessions/{id}` continues to accept full decisions array; now includes optional `command_output`
|
||||||
|
- **Validation:** Backend enforces 10,000 character hard limit (returns 422 on overflow); frontend shows live character count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Backend (3 files)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/schemas/session.py` | Add `command_output` field to `DecisionRecord` |
|
||||||
|
| `backend/app/services/export_service.py` | Render `command_output` in all 4 export formats with command context |
|
||||||
|
| `backend/app/core/session_to_tree.py` | Include command output when converting session decisions to forked tree nodes |
|
||||||
|
|
||||||
|
### Frontend (3 files)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `frontend/src/types/session.ts` | Add `command_output` to `DecisionRecord` type |
|
||||||
|
| `frontend/src/pages/TreeNavigationPage.tsx` | Add capture UI for both built-in action nodes and custom action steps |
|
||||||
|
| `frontend/src/pages/SessionDetailPage.tsx` | Render `command_output` in decision review and clipboard copy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Backend Schema
|
||||||
|
|
||||||
|
**File:** `backend/app/schemas/session.py`
|
||||||
|
|
||||||
|
- Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord`
|
||||||
|
- The `max_length=10000` provides backend validation — requests exceeding this return 422
|
||||||
|
|
||||||
|
### Step 2: Frontend Type
|
||||||
|
|
||||||
|
**File:** `frontend/src/types/session.ts`
|
||||||
|
|
||||||
|
- Add `command_output?: string | null` to the `DecisionRecord` type
|
||||||
|
- This ensures the field is not dropped by TypeScript typing during round-trips
|
||||||
|
|
||||||
|
### Step 3: TreeNavigationPage — Capture UI for Built-in Action Nodes
|
||||||
|
|
||||||
|
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
- Add `const [commandOutput, setCommandOutput] = useState('')` (same pattern as existing `notes` state)
|
||||||
|
- Clear `commandOutput` when node changes (same place `notes` is cleared)
|
||||||
|
- When revisiting a step, preload existing `command_output` from the decision record if present
|
||||||
|
|
||||||
|
**UI — on action nodes with `currentNode.commands?.length > 0`:**
|
||||||
|
- Render a collapsible "Paste Output (Optional)" section below the commands display
|
||||||
|
- Inside: a textarea with:
|
||||||
|
- Placeholder: `"Paste command output here..."`
|
||||||
|
- Monospace font (`font-mono`), consistent with command code block styling
|
||||||
|
- `bg-white/10` background styling to match existing design
|
||||||
|
- Live character count display: `"{count} / 10,000"` shown below the textarea
|
||||||
|
- Max length enforced on the frontend at 10,000 characters
|
||||||
|
- Use a `Terminal` icon from `lucide-react` for the section label
|
||||||
|
|
||||||
|
**Persistence — in `handleContinue()`:**
|
||||||
|
- Add `command_output: commandOutput.trim() || null` to the decision record pushed to the session
|
||||||
|
- Empty or whitespace-only input is normalized to `null` (treated as not provided)
|
||||||
|
|
||||||
|
### Step 4: TreeNavigationPage — Capture UI for Custom Action Steps
|
||||||
|
|
||||||
|
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
|
||||||
|
|
||||||
|
Custom action steps create their decision record at insertion time, which is different from built-in action nodes. The output capture UI and behavior should be the same as Step 3, but persistence requires updating the existing decision rather than creating a new one.
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- Same collapsible "Paste Output (Optional)" section as built-in action nodes
|
||||||
|
- Available when a custom action step has commands defined
|
||||||
|
|
||||||
|
**Persistence:**
|
||||||
|
- Before `handleContinueToDescendant` or `handleCustomBranchComplete` is called, update the current custom step's decision record with `command_output: commandOutput.trim() || null`
|
||||||
|
- Persist the updated decisions array to the backend before navigation/completion transitions
|
||||||
|
- This is a wrapper flow around the existing custom step logic — not a replacement of it
|
||||||
|
|
||||||
|
### Step 5: Export Service — All 4 Formats
|
||||||
|
|
||||||
|
**File:** `backend/app/services/export_service.py`
|
||||||
|
|
||||||
|
**Helpers to add:**
|
||||||
|
- A helper to safely extract and normalize `command_output` from a decision dict (strip whitespace, return `None` if empty)
|
||||||
|
- A helper to resolve the commands associated with a step for context display:
|
||||||
|
1. First look up the tree snapshot action node by `node_id`
|
||||||
|
2. Fallback to custom step metadata by `node_id`
|
||||||
|
3. Fallback to no command list (just show the output)
|
||||||
|
|
||||||
|
**Export rendering per format** (all guarded by `if command_output := decision.get("command_output")`):
|
||||||
|
|
||||||
|
**Markdown (`_generate_markdown_export`):**
|
||||||
|
```
|
||||||
|
**Commands Run:** `ping 8.8.8.8`, `tracert 8.8.8.8`
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
{output}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
**Text (`_generate_text_export`):**
|
||||||
|
```
|
||||||
|
Commands Run: ping 8.8.8.8, tracert 8.8.8.8
|
||||||
|
Output:
|
||||||
|
{output with each line indented}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTML (`_generate_html_export`):**
|
||||||
|
```html
|
||||||
|
<p><strong>Commands Run:</strong> <code>ping 8.8.8.8</code>, <code>tracert 8.8.8.8</code></p>
|
||||||
|
<pre><code>{html.escape(output)}</code></pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
**PSA (`_generate_psa_export`):**
|
||||||
|
```
|
||||||
|
Commands: ping 8.8.8.8, tracert 8.8.8.8
|
||||||
|
Output:
|
||||||
|
{output with each line indented}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: SessionDetailPage — Review Display
|
||||||
|
|
||||||
|
**File:** `frontend/src/pages/SessionDetailPage.tsx`
|
||||||
|
|
||||||
|
**Decision review:**
|
||||||
|
- After the `action_performed` rendering for each decision, check for `command_output`
|
||||||
|
- If present, render in a `<pre>` block with monospace styling and preserved whitespace
|
||||||
|
- Label: "Command Output" with consistent styling
|
||||||
|
|
||||||
|
**Clipboard copy (`copyTicketNotes`):**
|
||||||
|
- After the action performed line, add:
|
||||||
|
```
|
||||||
|
Output:
|
||||||
|
{decision.command_output}
|
||||||
|
```
|
||||||
|
- Only include if `command_output` is present and non-empty
|
||||||
|
|
||||||
|
### Step 7: Session-to-Tree Conversion
|
||||||
|
|
||||||
|
**File:** `backend/app/core/session_to_tree.py`
|
||||||
|
|
||||||
|
- When building node descriptions from decisions, check for `command_output`
|
||||||
|
- If present, append the output text to the node description so forked trees retain the captured output
|
||||||
|
- Format: include a "Command Output:" label followed by the output text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases & Failure Modes
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Existing sessions without `command_output` | Render normally with no errors — field is optional |
|
||||||
|
| Output exceeds 10,000 characters | Frontend prevents input beyond limit; backend returns 422 if somehow exceeded |
|
||||||
|
| Empty or whitespace-only input | Normalized to `null` — treated as not provided |
|
||||||
|
| Multiline output, JSON, special characters | Preserved as-is; HTML export escapes all content |
|
||||||
|
| Steps without commands | Output can still be stored; export shows output even without command context |
|
||||||
|
| Multi-command action nodes | One shared output field per step (not per command) |
|
||||||
|
| Revisiting a completed step | Preloads the previously captured output |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
### Backend API Tests (`test_sessions.py`)
|
||||||
|
|
||||||
|
1. Update a session with `command_output` in a decision record → verify it round-trips correctly on GET
|
||||||
|
2. Submit `command_output` exceeding 10,000 characters → verify 422 response
|
||||||
|
3. Submit empty string and whitespace-only `command_output` → verify stored as `null`
|
||||||
|
|
||||||
|
### Export Tests
|
||||||
|
|
||||||
|
4. Markdown export includes command context and fenced code block for output
|
||||||
|
5. Text export includes output block with preserved line breaks
|
||||||
|
6. HTML export includes escaped `<pre><code>` block
|
||||||
|
7. PSA export includes compact command context and indented output
|
||||||
|
8. Multi-command action node exports with single shared output block
|
||||||
|
9. Export of session without any `command_output` renders cleanly (no errors, no empty blocks)
|
||||||
|
|
||||||
|
### Custom Action Step Tests
|
||||||
|
|
||||||
|
10. Insert custom action step with commands → capture output → continue → verify output stored in decision
|
||||||
|
11. Custom step output appears in all export formats
|
||||||
|
|
||||||
|
### Frontend Behavior Tests
|
||||||
|
|
||||||
|
12. Action node with commands shows the "Paste Output" section (collapsed by default)
|
||||||
|
13. Custom action step with commands shows the "Paste Output" section
|
||||||
|
14. Action node WITHOUT commands does NOT show the "Paste Output" section
|
||||||
|
15. Character count updates live as user types
|
||||||
|
16. Revisiting a step preloads previously captured output
|
||||||
|
17. Session detail page renders output block with monospace formatting
|
||||||
|
18. "Copy to clipboard" includes command output when present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist (Manual)
|
||||||
|
|
||||||
|
1. `cd frontend && npm run build` — confirm no TypeScript errors
|
||||||
|
2. Start a session on a tree with action nodes that have commands:
|
||||||
|
- Paste output into the textarea
|
||||||
|
- Click Continue
|
||||||
|
- Verify output persists in the session data
|
||||||
|
3. Start a session and add a custom action step with commands:
|
||||||
|
- Paste output
|
||||||
|
- Continue to next step
|
||||||
|
- Verify output persists
|
||||||
|
4. Complete a session → check SessionDetailPage shows the command output with proper formatting
|
||||||
|
5. Export in all 4 formats → verify output appears correctly formatted in each
|
||||||
|
6. Use "Copy to clipboard" on a step with output → verify output is included
|
||||||
|
7. Run a session on a tree WITHOUT commands on action nodes → verify no output section appears
|
||||||
|
8. Test with existing sessions that have no `command_output` → verify they render and export without errors
|
||||||
|
9. Test pasting large output (near 10,000 chars) → verify character count and limit work
|
||||||
|
10. Test pasting multiline output with special characters → verify preservation in review and exports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- One shared output field per step, not per individual command
|
||||||
|
- Maximum stored output is 10,000 characters
|
||||||
|
- v1 does not include syntax highlighting or image paste
|
||||||
|
- No feature flag gating — ships directly
|
||||||
|
- Collapsed-by-default UI keeps the interface clean for steps where output isn't needed
|
||||||
@@ -224,6 +224,7 @@ export function SessionDetailPage() {
|
|||||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||||
|
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(lines.join('\n'))
|
await navigator.clipboard.writeText(lines.join('\n'))
|
||||||
setCopiedStepIndex(index)
|
setCopiedStepIndex(index)
|
||||||
@@ -439,6 +440,14 @@ export function SessionDetailPage() {
|
|||||||
Notes: {decision.notes}
|
Notes: {decision.notes}
|
||||||
</p>
|
</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 && (
|
{decision.duration_seconds != null && (
|
||||||
<p className="mt-2 text-xs text-white/50">
|
<p className="mt-2 text-xs text-white/50">
|
||||||
Duration: {formatDuration(decision.duration_seconds)}
|
Duration: {formatDuration(decision.duration_seconds)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
|||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
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 {
|
interface LocationState {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@@ -33,6 +33,8 @@ export function TreeNavigationPage() {
|
|||||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||||
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||||
const [notes, setNotes] = useState<string>('')
|
const [notes, setNotes] = useState<string>('')
|
||||||
|
const [commandOutput, setCommandOutput] = useState<string>('')
|
||||||
|
const [commandOutputOpen, setCommandOutputOpen] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
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) => {
|
const handleScratchpadSave = async (content: string) => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
await sessionsApi.updateScratchpad(session.id, content)
|
await sessionsApi.updateScratchpad(session.id, content)
|
||||||
@@ -218,6 +249,8 @@ export function TreeNavigationPage() {
|
|||||||
setCurrentNodeId(nextNodeId)
|
setCurrentNodeId(nextNodeId)
|
||||||
setCurrentStepEnteredAt(exitedAt)
|
setCurrentStepEnteredAt(exitedAt)
|
||||||
setNotes('')
|
setNotes('')
|
||||||
|
setCommandOutput('')
|
||||||
|
setCommandOutputOpen(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsApi.update(session.id, {
|
await sessionsApi.update(session.id, {
|
||||||
@@ -243,6 +276,7 @@ export function TreeNavigationPage() {
|
|||||||
answer: null,
|
answer: null,
|
||||||
action_performed: actionPerformed || node.title || 'Action completed',
|
action_performed: actionPerformed || node.title || 'Action completed',
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
command_output: commandOutput.trim() || null,
|
||||||
automation_used: false,
|
automation_used: false,
|
||||||
timestamp: exitedAt,
|
timestamp: exitedAt,
|
||||||
entered_at: enteredAt,
|
entered_at: enteredAt,
|
||||||
@@ -259,6 +293,8 @@ export function TreeNavigationPage() {
|
|||||||
setCurrentNodeId(node.next_node_id)
|
setCurrentNodeId(node.next_node_id)
|
||||||
setCurrentStepEnteredAt(exitedAt)
|
setCurrentStepEnteredAt(exitedAt)
|
||||||
setNotes('')
|
setNotes('')
|
||||||
|
setCommandOutput('')
|
||||||
|
setCommandOutputOpen(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsApi.update(session.id, {
|
await sessionsApi.update(session.id, {
|
||||||
@@ -280,6 +316,7 @@ export function TreeNavigationPage() {
|
|||||||
answer: null,
|
answer: null,
|
||||||
action_performed: node.title || 'Session completed',
|
action_performed: node.title || 'Session completed',
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
command_output: commandOutput.trim() || null,
|
||||||
automation_used: false,
|
automation_used: false,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
@@ -321,11 +358,16 @@ export function TreeNavigationPage() {
|
|||||||
const handleGoBack = () => {
|
const handleGoBack = () => {
|
||||||
if (pathTaken.length <= 1) return
|
if (pathTaken.length <= 1) return
|
||||||
const newPath = pathTaken.slice(0, -1)
|
const newPath = pathTaken.slice(0, -1)
|
||||||
|
const removedDecision = decisions[decisions.length - 1]
|
||||||
const newDecisions = decisions.slice(0, -1)
|
const newDecisions = decisions.slice(0, -1)
|
||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setDecisions(newDecisions)
|
setDecisions(newDecisions)
|
||||||
setCurrentNodeId(newPath[newPath.length - 1])
|
setCurrentNodeId(newPath[newPath.length - 1])
|
||||||
setCurrentStepEnteredAt(new Date().toISOString())
|
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)
|
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||||
@@ -614,6 +656,37 @@ export function TreeNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -625,7 +698,7 @@ export function TreeNavigationPage() {
|
|||||||
<div className="mt-6 border-t border-purple-700 pt-4">
|
<div className="mt-6 border-t border-purple-700 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={customStepFlow.handleContinueToDescendant}
|
onClick={handleCustomContinueToDescendant}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center justify-between rounded-md bg-white px-4 py-3 text-sm font-medium text-black',
|
'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'
|
'hover:bg-white/90'
|
||||||
@@ -656,7 +729,7 @@ export function TreeNavigationPage() {
|
|||||||
Add Another Step
|
Add Another Step
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={customStepFlow.handleCustomBranchComplete}
|
onClick={handleCustomBranchCompleteWithOutput}
|
||||||
disabled={isCompleting}
|
disabled={isCompleting}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
|
'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>
|
</code>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentNode.expected_outcome && (
|
{currentNode.expected_outcome && (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface DecisionRecord {
|
|||||||
answer: string | null
|
answer: string | null
|
||||||
action_performed: string | null
|
action_performed: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
command_output?: string | null
|
||||||
automation_used: boolean
|
automation_used: boolean
|
||||||
timestamp: string
|
timestamp: string
|
||||||
entered_at?: string | null
|
entered_at?: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user