@shendeguize/dsh-agent-sidecar 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -11
- package/lib/client.js +4708 -2932
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/analysis/AnalysisPanel.tsx +19 -27
- package/src/client/analysis/analysis.module.css +36 -97
- package/src/client/board/Board.tsx +255 -48
- package/src/client/board/board.module.css +113 -92
- package/src/client/board/logic.ts +99 -21
- package/src/client/board/project-view-logic.ts +27 -34
- package/src/client/board/project-view.module.css +46 -83
- package/src/client/board/project-view.tsx +50 -8
- package/src/client/board/strings.ts +70 -85
- package/src/client/commands.ts +30 -15
- package/src/client/controller.ts +27 -7
- package/src/client/detail/SessionDetail.tsx +234 -37
- package/src/client/detail/detail.module.css +100 -153
- package/src/client/detail/logic.ts +220 -29
- package/src/client/detail/strings.ts +66 -77
- package/src/client/detail-glue.ts +33 -0
- package/src/client/detail-view.module.css +43 -35
- package/src/client/detail-view.tsx +138 -118
- package/src/client/dsh-tools/LineageTree.tsx +22 -13
- package/src/client/dsh-tools/SearchPanel.tsx +18 -11
- package/src/client/dsh-tools/dsh-tools.module.css +53 -140
- package/src/client/dsh-tools/strings.ts +42 -77
- package/src/client/index.ts +285 -124
- package/src/client/inject/InjectPanel.tsx +81 -61
- package/src/client/inject/inject.module.css +97 -197
- package/src/client/inject/logic.ts +38 -0
- package/src/client/lifecycle/handoff.ts +83 -0
- package/src/client/locales/en.ts +91 -4
- package/src/client/locales/host.ts +131 -0
- package/src/client/locales/index.ts +196 -8
- package/src/client/locales/react.ts +10 -0
- package/src/client/locales/view.ts +38 -0
- package/src/client/locales/zh.ts +200 -125
- package/src/client/mount.tsx +75 -41
- package/src/client/navigation/CenterOverlay.tsx +40 -0
- package/src/client/navigation/center-overlay.module.css +51 -0
- package/src/client/navigation/center.ts +45 -0
- package/src/client/navigation/modal-isolation.ts +152 -0
- package/src/client/navigation/modal-surface-anchor.ts +72 -0
- package/src/client/navigation/sidebar-entry.module.css +73 -0
- package/src/client/navigation/sidebar-entry.ts +309 -0
- package/src/client/primitives/StaticPill.tsx +21 -0
- package/src/client/settings-card.module.css +35 -119
- package/src/client/settings-card.tsx +31 -153
- package/src/client/settings-fields.tsx +131 -0
- package/src/client/sidebar/SidebarTab.tsx +156 -0
- package/src/client/sidebar/model.ts +65 -0
- package/src/client/sidebar/sidebar-tab.module.css +118 -0
- package/src/client/sidebar-tab.tsx +46 -320
- package/src/client/theme/agsc.module.css +31 -0
- package/src/client/theme/parts.ts +56 -0
- package/src/client/ui-integration.ts +86 -0
- package/src/client/widget.tsx +42 -16
- package/src/client/inject/overlay.module.css +0 -22
|
@@ -7,17 +7,28 @@
|
|
|
7
7
|
* from the host wire types (epoch-seconds → epoch-ms conversion included).
|
|
8
8
|
*
|
|
9
9
|
* Interaction surface handed back to the owner:
|
|
10
|
-
* - `onFiltersChange` — time-window select / show-dead checkbox
|
|
11
|
-
* -
|
|
10
|
+
* - `onFiltersChange` — time-window select / show-dead checkbox / the
|
|
11
|
+
* top-bar status-filter badges (controlled, UX-01);
|
|
12
|
+
* - `onRefresh` — manual snapshot pull button; a Promise<boolean>
|
|
13
|
+
* return drives the in-flight/failure feedback (UX-07);
|
|
12
14
|
* - `onSelectSession` — card click, pass-through for the M3 detail view.
|
|
15
|
+
*
|
|
16
|
+
* Local UI state (deliberately NOT lifted into the controller stores):
|
|
17
|
+
* group collapse and per-group truncation (UX-02) are ephemeral view
|
|
18
|
+
* concerns — useState here, reset on tab remount.
|
|
13
19
|
*/
|
|
14
20
|
|
|
21
|
+
import { Button, Pill, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
22
|
+
import { useEffect, useRef, useState } from 'react'
|
|
15
23
|
import type { ReactElement } from 'react'
|
|
16
24
|
import {
|
|
17
25
|
buildBoardViewModel,
|
|
18
|
-
timeWindowLabel,
|
|
19
26
|
formatTemplate,
|
|
27
|
+
sliceCardsForDisplay,
|
|
28
|
+
timeWindowLabel,
|
|
29
|
+
GROUP_CARD_LIMIT,
|
|
20
30
|
type BoardFilterState,
|
|
31
|
+
type BoardStatusFilter,
|
|
21
32
|
type DaemonStateToken,
|
|
22
33
|
type DerivedSessionCardVM,
|
|
23
34
|
type ProjectGroupVM,
|
|
@@ -25,6 +36,7 @@ import {
|
|
|
25
36
|
type StreamHealthToken,
|
|
26
37
|
} from './logic.ts'
|
|
27
38
|
import { BOARD_STRINGS } from './strings.ts'
|
|
39
|
+
import { surfaceProps } from '../theme/parts.ts'
|
|
28
40
|
import styles from './board.module.css'
|
|
29
41
|
|
|
30
42
|
/** Time-window choices offered by the top bar (hours). */
|
|
@@ -41,54 +53,118 @@ export interface BoardProps {
|
|
|
41
53
|
/** Controlled filter state (owner persists it to the settings namespace). */
|
|
42
54
|
filters: BoardFilterState
|
|
43
55
|
onFiltersChange: (next: BoardFilterState) => void
|
|
44
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Manual snapshot pull. A `Promise<boolean>` return (true = snapshot
|
|
58
|
+
* applied) lets the board render the in-flight state and a dismissible
|
|
59
|
+
* failure notice; a void return keeps the button fire-and-forget.
|
|
60
|
+
*/
|
|
61
|
+
onRefresh: () => void | Promise<boolean>
|
|
45
62
|
onSelectSession: (sessionId: string) => void
|
|
46
63
|
/** Clock injection for deterministic rendering; defaults to Date.now(). */
|
|
47
64
|
nowMs?: number
|
|
48
65
|
}
|
|
49
66
|
|
|
67
|
+
function sessionDotState(status: DerivedSessionCardVM['badge']['status']): StateDotState | null {
|
|
68
|
+
if (status === 'working') return 'ongoing'
|
|
69
|
+
if (status === 'waiting') return 'warning'
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
function SessionCard(props: {
|
|
51
74
|
card: DerivedSessionCardVM
|
|
52
75
|
onSelect: (sessionId: string) => void
|
|
53
76
|
}): ReactElement {
|
|
54
77
|
const { card, onSelect } = props
|
|
78
|
+
const [copied, setCopied] = useState(false)
|
|
79
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
80
|
+
const copyAliveRef = useRef(true)
|
|
81
|
+
const dotState = sessionDotState(card.badge.status)
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
copyAliveRef.current = true
|
|
85
|
+
return () => {
|
|
86
|
+
copyAliveRef.current = false
|
|
87
|
+
if (copyTimerRef.current !== null) {
|
|
88
|
+
clearTimeout(copyTimerRef.current)
|
|
89
|
+
copyTimerRef.current = null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [])
|
|
93
|
+
|
|
94
|
+
// UX-17: opening and copying are sibling buttons inside a semantic card,
|
|
95
|
+
// so both actions are independently keyboard reachable.
|
|
96
|
+
const onCopyId = (): void => {
|
|
97
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
98
|
+
if (clipboard === undefined) return
|
|
99
|
+
clipboard.writeText(card.sessionId).then(
|
|
100
|
+
() => {
|
|
101
|
+
if (!copyAliveRef.current) return
|
|
102
|
+
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
|
|
103
|
+
setCopied(true)
|
|
104
|
+
copyTimerRef.current = setTimeout(() => {
|
|
105
|
+
copyTimerRef.current = null
|
|
106
|
+
setCopied(false)
|
|
107
|
+
}, 2000)
|
|
108
|
+
},
|
|
109
|
+
() => {
|
|
110
|
+
// Clipboard permission denied: the hover title still carries the id.
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
55
115
|
return (
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
className={styles['card']}
|
|
59
|
-
onClick={() => onSelect(card.sessionId)}
|
|
116
|
+
<article
|
|
117
|
+
{...surfaceProps('board-card', styles['card'])}
|
|
60
118
|
data-testid="agent-sidecar-card"
|
|
61
119
|
>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className={styles['cardOpen']}
|
|
123
|
+
onClick={() => onSelect(card.sessionId)}
|
|
124
|
+
>
|
|
125
|
+
<span className={styles['cardHead']}>
|
|
126
|
+
<span className={styles['agent']}>
|
|
127
|
+
<span className={styles['glyph']} aria-hidden>
|
|
128
|
+
{card.glyph}
|
|
129
|
+
</span>
|
|
130
|
+
{card.agent}
|
|
131
|
+
</span>
|
|
132
|
+
<span title={card.hoverTitle}>
|
|
133
|
+
<Pill className={styles['statusPill']}>
|
|
134
|
+
{dotState === null
|
|
135
|
+
? <span className={styles['dot']} data-tone={card.badge.tone} />
|
|
136
|
+
: <StateDot state={dotState} size={8} />}
|
|
137
|
+
{card.badge.label}
|
|
138
|
+
{card.badge.attention !== null && (
|
|
139
|
+
<span className={styles['attention']} data-kind={card.badge.attention}>
|
|
140
|
+
{card.badge.attentionLabel}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</Pill>
|
|
66
144
|
</span>
|
|
67
|
-
{card.agent}
|
|
68
145
|
</span>
|
|
69
|
-
<span className={styles['
|
|
70
|
-
|
|
71
|
-
{card.badge.label}
|
|
72
|
-
{card.badge.attention !== null && (
|
|
73
|
-
<span className={styles['attention']} data-kind={card.badge.attention}>
|
|
74
|
-
{card.badge.attentionLabel}
|
|
75
|
-
</span>
|
|
76
|
-
)}
|
|
146
|
+
<span className={styles['cardTitle']} title={card.title}>
|
|
147
|
+
{card.title.trim() === '' ? BOARD_STRINGS.card.untitled : card.title}
|
|
77
148
|
</span>
|
|
78
|
-
</
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
149
|
+
</button>
|
|
150
|
+
<Button
|
|
151
|
+
size="sm"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
className={styles['cardId']}
|
|
154
|
+
title={`${card.sessionId}\n${BOARD_STRINGS.card.copyId}`}
|
|
155
|
+
onClick={onCopyId}
|
|
156
|
+
data-testid="agent-sidecar-card-id"
|
|
157
|
+
>
|
|
83
158
|
{card.shortId}
|
|
84
|
-
|
|
159
|
+
{copied && <span className={styles['copied']} role="status">{BOARD_STRINGS.card.copied}</span>}
|
|
160
|
+
</Button>
|
|
85
161
|
<div className={styles['cardEvent']}>
|
|
86
162
|
{card.lastEvent === null
|
|
87
163
|
? BOARD_STRINGS.card.noEvent
|
|
88
164
|
: `${card.lastEvent.kind} · ${card.lastEvent.text}`}
|
|
89
165
|
</div>
|
|
90
166
|
<div className={styles['cardTime']}>{card.relativeTime}</div>
|
|
91
|
-
</
|
|
167
|
+
</article>
|
|
92
168
|
)
|
|
93
169
|
}
|
|
94
170
|
|
|
@@ -97,9 +173,25 @@ function ProjectGroup(props: {
|
|
|
97
173
|
onSelect: (sessionId: string) => void
|
|
98
174
|
}): ReactElement {
|
|
99
175
|
const { group, onSelect } = props
|
|
176
|
+
// UX-02: collapse + truncation are per-group ephemeral view state.
|
|
177
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
178
|
+
const [expanded, setExpanded] = useState(false)
|
|
179
|
+
const { shown, hiddenCount } = sliceCardsForDisplay(group.cards, GROUP_CARD_LIMIT, expanded)
|
|
180
|
+
// Honesty guard: a collapsed group must not silently hide waiting
|
|
181
|
+
// sessions, so the header keeps a waiting counter while folded.
|
|
182
|
+
const waitingInGroup = group.cards.filter((card) => card.badge.status === 'waiting').length
|
|
100
183
|
return (
|
|
101
184
|
<section className={styles['group']}>
|
|
102
|
-
<
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
className={styles['groupHead']}
|
|
188
|
+
aria-expanded={!collapsed}
|
|
189
|
+
title={collapsed ? BOARD_STRINGS.group.expandTitle : BOARD_STRINGS.group.collapseTitle}
|
|
190
|
+
onClick={() => { setCollapsed(!collapsed) }}
|
|
191
|
+
>
|
|
192
|
+
<span className={styles['chevron']} aria-hidden>
|
|
193
|
+
{collapsed ? '▸' : '▾'}
|
|
194
|
+
</span>
|
|
103
195
|
<span
|
|
104
196
|
className={styles['groupName']}
|
|
105
197
|
title={group.fullPath === '' ? undefined : group.fullPath}
|
|
@@ -109,12 +201,41 @@ function ProjectGroup(props: {
|
|
|
109
201
|
<span className={styles['groupCount']}>
|
|
110
202
|
{formatTemplate(BOARD_STRINGS.groupCount, { n: group.cards.length })}
|
|
111
203
|
</span>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
</
|
|
204
|
+
{collapsed && waitingInGroup > 0 && (
|
|
205
|
+
<span className={styles['groupAttention']}>
|
|
206
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: waitingInGroup })}
|
|
207
|
+
</span>
|
|
208
|
+
)}
|
|
209
|
+
</button>
|
|
210
|
+
{!collapsed && (
|
|
211
|
+
<>
|
|
212
|
+
<div className={styles['grid']}>
|
|
213
|
+
{shown.map((card) => (
|
|
214
|
+
<SessionCard key={`${card.agent}:${card.sessionId}`} card={card} onSelect={onSelect} />
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
{hiddenCount > 0 && (
|
|
218
|
+
<Button
|
|
219
|
+
size="sm"
|
|
220
|
+
variant="outline"
|
|
221
|
+
className={styles['showMore']}
|
|
222
|
+
onClick={() => { setExpanded(true) }}
|
|
223
|
+
>
|
|
224
|
+
{formatTemplate(BOARD_STRINGS.group.showAll, { n: group.cards.length })}
|
|
225
|
+
</Button>
|
|
226
|
+
)}
|
|
227
|
+
{expanded && group.cards.length > GROUP_CARD_LIMIT && (
|
|
228
|
+
<Button
|
|
229
|
+
size="sm"
|
|
230
|
+
variant="outline"
|
|
231
|
+
className={styles['showMore']}
|
|
232
|
+
onClick={() => { setExpanded(false) }}
|
|
233
|
+
>
|
|
234
|
+
{formatTemplate(BOARD_STRINGS.group.showLess, { n: GROUP_CARD_LIMIT })}
|
|
235
|
+
</Button>
|
|
236
|
+
)}
|
|
237
|
+
</>
|
|
238
|
+
)}
|
|
118
239
|
</section>
|
|
119
240
|
)
|
|
120
241
|
}
|
|
@@ -136,17 +257,88 @@ export function Board(props: BoardProps): ReactElement {
|
|
|
136
257
|
? TIME_WINDOW_OPTIONS
|
|
137
258
|
: [...TIME_WINDOW_OPTIONS, props.filters.timeWindowHours].sort((a, b) => a - b)
|
|
138
259
|
|
|
260
|
+
// UX-07: manual-refresh feedback (in-flight + dismissible failure line).
|
|
261
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
262
|
+
const [refreshFailed, setRefreshFailed] = useState(false)
|
|
263
|
+
const onRefreshClick = (): void => {
|
|
264
|
+
if (refreshing) return
|
|
265
|
+
setRefreshFailed(false)
|
|
266
|
+
const result = props.onRefresh()
|
|
267
|
+
if (result instanceof Promise) {
|
|
268
|
+
setRefreshing(true)
|
|
269
|
+
result
|
|
270
|
+
.then((ok) => { setRefreshFailed(!ok) })
|
|
271
|
+
.catch(() => { setRefreshFailed(true) })
|
|
272
|
+
.finally(() => { setRefreshing(false) })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// UX-01: the working/waiting badges toggle the status-only view.
|
|
277
|
+
const toggleStatusFilter = (status: BoardStatusFilter): void => {
|
|
278
|
+
const next: BoardFilterState = { ...props.filters }
|
|
279
|
+
if (next.statusFilter === status) delete next.statusFilter
|
|
280
|
+
else next.statusFilter = status
|
|
281
|
+
props.onFiltersChange(next)
|
|
282
|
+
}
|
|
283
|
+
const statusBadgeTitle = (status: BoardStatusFilter): string =>
|
|
284
|
+
props.filters.statusFilter === status
|
|
285
|
+
? BOARD_STRINGS.topbar.clearStatusFilterTitle
|
|
286
|
+
: formatTemplate(BOARD_STRINGS.topbar.filterByStatusTitle, {
|
|
287
|
+
label: BOARD_STRINGS.status[status],
|
|
288
|
+
})
|
|
289
|
+
const daemonDotState: StateDotState =
|
|
290
|
+
props.daemonState === 'failed'
|
|
291
|
+
? 'error'
|
|
292
|
+
: props.daemonState === 'defer' || props.daemonState === 'backoff'
|
|
293
|
+
? 'warning'
|
|
294
|
+
: props.daemonState === 'adopted' || props.daemonState === 'hosted'
|
|
295
|
+
? 'done'
|
|
296
|
+
: 'ongoing'
|
|
297
|
+
const streamDotState: StateDotState =
|
|
298
|
+
props.streamHealth === 'ok' ? 'done' : props.streamHealth === 'degraded' ? 'warning' : 'ongoing'
|
|
299
|
+
|
|
139
300
|
return (
|
|
140
|
-
<div
|
|
141
|
-
<header
|
|
301
|
+
<div {...surfaceProps('board', styles['root'])} data-testid="agent-sidecar-board">
|
|
302
|
+
<header {...surfaceProps('board-toolbar', styles['topbar'])}>
|
|
142
303
|
<span className={styles['title']}>{BOARD_STRINGS.topbar.title}</span>
|
|
143
|
-
<span
|
|
144
|
-
<
|
|
145
|
-
|
|
304
|
+
<span title={props.daemonDetail}>
|
|
305
|
+
<Pill>
|
|
306
|
+
<StateDot state={daemonDotState} size={8} />
|
|
307
|
+
{vm.daemonBadge.label}
|
|
308
|
+
</Pill>
|
|
146
309
|
</span>
|
|
147
|
-
<
|
|
148
|
-
<
|
|
310
|
+
<Pill>
|
|
311
|
+
<StateDot state={streamDotState} size={8} />
|
|
149
312
|
{vm.streamLabel}
|
|
313
|
+
</Pill>
|
|
314
|
+
<Pill
|
|
315
|
+
className={styles['countBadge']}
|
|
316
|
+
active={props.filters.statusFilter === 'working'}
|
|
317
|
+
aria-pressed={props.filters.statusFilter === 'working'}
|
|
318
|
+
title={statusBadgeTitle('working')}
|
|
319
|
+
onClick={() => { toggleStatusFilter('working') }}
|
|
320
|
+
data-testid="agent-sidecar-count-working"
|
|
321
|
+
>
|
|
322
|
+
{vm.workingCount > 0
|
|
323
|
+
? <StateDot state="ongoing" size={8} />
|
|
324
|
+
: <span className={styles['dot']} data-tone="neutral" />}
|
|
325
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWorking, { n: vm.workingCount })}
|
|
326
|
+
</Pill>
|
|
327
|
+
<Pill
|
|
328
|
+
className={styles['countBadge']}
|
|
329
|
+
active={props.filters.statusFilter === 'waiting'}
|
|
330
|
+
aria-pressed={props.filters.statusFilter === 'waiting'}
|
|
331
|
+
title={statusBadgeTitle('waiting')}
|
|
332
|
+
onClick={() => { toggleStatusFilter('waiting') }}
|
|
333
|
+
data-testid="agent-sidecar-count-waiting"
|
|
334
|
+
>
|
|
335
|
+
{vm.waitingCount > 0
|
|
336
|
+
? <StateDot state="warning" size={8} />
|
|
337
|
+
: <span className={styles['dot']} data-tone="neutral" />}
|
|
338
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: vm.waitingCount })}
|
|
339
|
+
</Pill>
|
|
340
|
+
<span className={styles['countTotal']} data-testid="agent-sidecar-count-total">
|
|
341
|
+
{formatTemplate(BOARD_STRINGS.topbar.countTotal, { n: vm.totalCount })}
|
|
150
342
|
</span>
|
|
151
343
|
<span className={styles['spacer']} />
|
|
152
344
|
<label className={styles['control']}>
|
|
@@ -179,16 +371,31 @@ export function Board(props: BoardProps): ReactElement {
|
|
|
179
371
|
/>
|
|
180
372
|
{BOARD_STRINGS.topbar.showDead}
|
|
181
373
|
</label>
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
374
|
+
<Button
|
|
375
|
+
size="sm"
|
|
376
|
+
variant="toolbar"
|
|
185
377
|
title={BOARD_STRINGS.topbar.refreshTitle}
|
|
186
|
-
|
|
378
|
+
disabled={refreshing}
|
|
379
|
+
onClick={onRefreshClick}
|
|
187
380
|
>
|
|
188
|
-
{BOARD_STRINGS.topbar.refresh}
|
|
189
|
-
</
|
|
381
|
+
{refreshing ? BOARD_STRINGS.topbar.refreshing : BOARD_STRINGS.topbar.refresh}
|
|
382
|
+
</Button>
|
|
190
383
|
</header>
|
|
191
384
|
|
|
385
|
+
{refreshFailed && (
|
|
386
|
+
<div className={styles['banner']} data-tone="warn" role="status">
|
|
387
|
+
{BOARD_STRINGS.topbar.refreshFailed}
|
|
388
|
+
<Button
|
|
389
|
+
size="sm"
|
|
390
|
+
variant="ghost"
|
|
391
|
+
className={styles['bannerDismiss']}
|
|
392
|
+
onClick={() => { setRefreshFailed(false) }}
|
|
393
|
+
>
|
|
394
|
+
{BOARD_STRINGS.topbar.dismiss}
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
192
399
|
{vm.banner !== null && (
|
|
193
400
|
<div className={styles['banner']} data-tone={vm.banner.tone} role="status">
|
|
194
401
|
{vm.banner.text}
|