File size: 4,945 Bytes
7c5ed63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { useEffect } from 'react';

import type { Issue, IssueSeverity } from '../../lib/issues';

interface IssuesDrawerProps {
  issues: Issue[];
  onClose: () => void;
  onJumpTo?: (lineNo: number) => void;
  /** Optional predicate: "is this line currently visible under the active
   *  filter?". When provided, jump buttons for filtered-out lines are
   *  disabled and flagged. */
  isLineVisible?: (lineNo: number) => boolean;
}

const SEV_COLOR: Record<IssueSeverity, string> = {
  error: 'var(--color-sev-error)',
  warning: 'var(--color-sev-warning)',
  info: 'var(--color-sev-info)',
};

const KIND_LABEL: Record<Issue['kind'], string> = {
  orphan_tool_call: 'orphan tool.call',
  missing_tool_result: 'missing tool.result',
  tool_error: 'tool error',
  tool_truncated: 'tool output truncated',
  model_filtered: 'response filtered',
  model_max_tokens: 'hit max_tokens',
  incomplete_step: 'incomplete step',
  incomplete_compaction: 'incomplete compaction',
  active_plan_mode: 'plan mode active',
  rejected_approval: 'approval rejected',
  wire_warning: 'wire warning',
};

export function IssuesDrawer({ issues, onClose, onJumpTo, isLineVisible }: IssuesDrawerProps) {
  // ESC closes — standard drawer affordance.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('keydown', onKey);
    };
  }, [onClose]);

  return (
    <>
      <button
        type="button"
        aria-label="close issues"
        onClick={onClose}
        className="absolute inset-0 z-10 bg-black/20"
      />
      <aside
        className="absolute right-0 top-0 bottom-0 z-20 flex w-[360px] flex-col border-l border-border bg-surface-1 shadow-[-8px_0_32px_rgba(0,0,0,0.25)]"
        role="dialog"
        aria-label="issues"
      >
        <header className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2 font-mono text-[12px] text-fg-0">
          <span>
            Issues <span className="text-fg-3">·</span>{' '}
            <span className="tabular text-fg-2">{issues.length}</span>
          </span>
          <button
            type="button"
            onClick={onClose}
            className="font-mono text-[14px] text-fg-3 hover:text-fg-0"
            title="close (ESC)"
          >
            ×
          </button>
        </header>
        <div className="min-h-0 flex-1 overflow-y-auto">
          {issues.length === 0 ? (
            <div className="p-6 font-mono text-[12px] text-fg-3">no issues detected</div>
          ) : (
            <ul className="divide-y divide-border">
              {issues.map((iss, i) => (
                <IssueItem
                  key={`${iss.kind}-${iss.lineNo ?? 'w'}-${i}`}
                  issue={iss}
                  onJumpTo={onJumpTo}
                  onClose={onClose}
                  isLineVisible={isLineVisible}
                />
              ))}
            </ul>
          )}
        </div>
      </aside>
    </>
  );
}

function IssueItem({
  issue,
  onJumpTo,
  onClose,
  isLineVisible,
}: {
  issue: Issue;
  onJumpTo?: (lineNo: number) => void;
  onClose: () => void;
  isLineVisible?: (lineNo: number) => boolean;
}) {
  const color = SEV_COLOR[issue.severity];
  const lineNo = issue.lineNo;
  const hidden = lineNo !== null && isLineVisible !== undefined && !isLineVisible(lineNo);
  const canJump = lineNo !== null && onJumpTo !== undefined && !hidden;
  return (
    <li className="px-3 py-2 hover:bg-surface-2">
      <div className="flex items-center gap-2 font-mono text-[11px]">
        <span
          className="inline-block h-[8px] w-[8px] shrink-0 rounded-full"
          style={{ backgroundColor: color }}
          aria-hidden="true"
        />
        <span className="text-fg-1">{KIND_LABEL[issue.kind]}</span>
        {lineNo !== null ? (
          <>
            <span className="text-fg-3">·</span>
            <span className="tabular text-fg-3">line {lineNo}</span>
          </>
        ) : null}
        {hidden ? <span className="text-fg-3">(filtered out)</span> : null}
        {lineNo !== null ? (
          <button
            type="button"
            disabled={!canJump}
            onClick={() => {
              if (canJump) onJumpTo?.(lineNo);
              onClose();
            }}
            className="ml-auto text-fg-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-3"
            title={hidden ? 'record is filtered out' : 'scroll to + expand'}
          >
            jump →
          </button>
        ) : null}
      </div>
      <div className="mt-1 break-words font-mono text-[12px] text-fg-0">{issue.summary}</div>
      {issue.detail !== undefined ? (
        <div className="mt-0.5 break-words font-mono text-[10.5px] text-fg-3">{issue.detail}</div>
      ) : null}
    </li>
  );
}