@xpufx/paseo-top 0.4.2 → 0.4.4

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.
Files changed (43) hide show
  1. package/README.md +11 -12
  2. package/client/multi-host-view.tsx +177 -0
  3. package/client/multi-host.ts +98 -0
  4. package/client/pill-labels.ts +30 -6
  5. package/client/pill.tsx +42 -61
  6. package/client/settings-events.ts +33 -0
  7. package/client/surface.tsx +10 -9
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +29 -3
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  11. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  12. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +17 -2
  13. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  14. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  15. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  16. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  17. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  19. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  20. package/client/vendor/paseo-plugin-helper/index.ts +2 -0
  21. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +19 -29
  22. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
  23. package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
  24. package/client/vendor/paseo-plugin-helper/pill.tsx +135 -339
  25. package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
  26. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
  27. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  28. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
  29. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
  30. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  31. package/index.server.ts +7 -0
  32. package/package.json +12 -3
  33. package/paseo-plugin.json +1 -1
  34. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  35. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  36. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  37. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  38. package/shared/helper-version.ts +20 -0
  39. package/shared/multi-host.ts +348 -0
  40. package/shared/resources.ts +1 -1
  41. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  42. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  43. package/shared/version.ts +1 -1
@@ -0,0 +1,766 @@
1
+ import React, { useState, useMemo } from "react";
2
+ import { View, Text, Linking, StyleSheet } from "react-native";
3
+ import { usePluginTheme } from "./theme/index";
4
+ import { getClientHost } from "./host";
5
+ import { useRpcQuery, useRpcMutation } from "./query";
6
+ import { Button } from "./components/Button";
7
+ import { Card } from "./components/Card";
8
+ import { Badge } from "./components/Badge";
9
+ import { Stack } from "./layout/Stack";
10
+ import { Row } from "./layout/Row";
11
+ import { KeyValue, KeyValueGroup } from "./components/KeyValue";
12
+ import { CommandBox } from "./components/CommandBox";
13
+ import { CopyButton } from "./components/CopyButton";
14
+ import { CodeBlock } from "./components/CodeBlock";
15
+ import { HighlightedText } from "./components/HighlightedText";
16
+ import { Collapsible } from "./components/Collapsible";
17
+ import { FormRow } from "./layout/FormRow";
18
+ import { ActionBar } from "./layout/ActionBar";
19
+ import { TextInput } from "./components/TextInput";
20
+ import { EmptyState } from "./components/EmptyState";
21
+ import { copyToClipboard } from "./utils/clipboard";
22
+ import {
23
+ STATE_ORDER,
24
+ PRIORITY_ORDER,
25
+ ATTENTION_LABELS,
26
+ SPEC_LABELS,
27
+ shortLabelName,
28
+ currentStateLabel,
29
+ currentPriorityLabel,
30
+ nextStateLabel,
31
+ parseLabelList,
32
+ writeGateNotice,
33
+ stripAgentEnvelopeFooter,
34
+ parseMarkdownLite,
35
+ issueDetailContract,
36
+ setLabelContract,
37
+ addCommentContract,
38
+ createIssueContract,
39
+ type ForgeIssue,
40
+ type IssueComment,
41
+ type AgentEnvelope,
42
+ type ForgeAccessState,
43
+ type ForgeLabel,
44
+ type MarkdownLiteSpan,
45
+ } from "../../../shared/vendor/paseo-plugin-helper/tickets";
46
+
47
+ function formatTimestamp(value: string | undefined | null): string {
48
+ if (!value) return "unknown";
49
+ const parsed = new Date(value);
50
+ if (Number.isNaN(parsed.getTime())) return value;
51
+ return parsed.toLocaleString();
52
+ }
53
+
54
+ function editorLabelChips(
55
+ names: readonly string[],
56
+ known: Map<string, ForgeLabel>,
57
+ ): ForgeLabel[] {
58
+ return names.map((name) => known.get(name) ?? { name });
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // MarkdownLite
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function renderInlineSpans(
66
+ spans: MarkdownLiteSpan[],
67
+ colors: { foreground: string; accent: string },
68
+ keyPrefix: string,
69
+ query: string,
70
+ ) {
71
+ return spans.map((span, index) => {
72
+ const key = `${keyPrefix}-${index}`;
73
+ if (span.kind === "bold") {
74
+ return (
75
+ <Text key={key} style={styles.bold}>
76
+ <HighlightedText text={span.text} query={query} />
77
+ </Text>
78
+ );
79
+ }
80
+ if (span.kind === "italic") {
81
+ return (
82
+ <Text key={key} style={styles.italic}>
83
+ <HighlightedText text={span.text} query={query} />
84
+ </Text>
85
+ );
86
+ }
87
+ if (span.kind === "code") {
88
+ return (
89
+ <Text key={key} style={[styles.inlineCode, { backgroundColor: colors.accent + "18" }]}>
90
+ <HighlightedText text={span.text} query={query} />
91
+ </Text>
92
+ );
93
+ }
94
+ if (span.kind === "link") {
95
+ return (
96
+ <Text
97
+ key={key}
98
+ style={[styles.link, { color: colors.accent }]}
99
+ onPress={() => {
100
+ Linking.openURL(span.url).catch(() => {
101
+ copyToClipboard(span.url).catch(() => {});
102
+ });
103
+ }}
104
+ >
105
+ <HighlightedText text={span.text} query={query} />
106
+ </Text>
107
+ );
108
+ }
109
+ return <HighlightedText key={key} text={span.text} query={query} />;
110
+ });
111
+ }
112
+
113
+ export function MarkdownLite({
114
+ body,
115
+ query = "",
116
+ }: {
117
+ body: string;
118
+ query?: string;
119
+ }) {
120
+ const { colors, typography } = usePluginTheme();
121
+ const blocks = useMemo(() => parseMarkdownLite(body), [body]);
122
+
123
+ return (
124
+ <Stack gap="xs" style={styles.markdownContainer}>
125
+ {blocks.map((block, index) => {
126
+ if (block.kind === "code") {
127
+ return <CodeBlock key={index} code={block.text} language={block.language} />;
128
+ }
129
+ if (block.kind === "heading") {
130
+ const fontSize = block.level === 1 ? 18 : block.level === 2 ? 16 : 14;
131
+ return (
132
+ <Text
133
+ key={index}
134
+ selectable
135
+ style={[
136
+ typography.heading,
137
+ { fontSize, fontWeight: "700", color: colors.foreground, marginTop: 4 },
138
+ ]}
139
+ >
140
+ {renderInlineSpans(block.spans, colors, `h${index}`, query)}
141
+ </Text>
142
+ );
143
+ }
144
+ if (block.kind === "list") {
145
+ return (
146
+ <Stack key={index} gap="xxs" style={styles.listContainer}>
147
+ {block.items.map((item, itemIndex) => (
148
+ <Row key={itemIndex} align="flex-start" gap="xs">
149
+ <Text selectable style={[typography.body, { color: colors.foregroundMuted }]}>
150
+ {block.ordered ? `${itemIndex + 1}.` : "•"}
151
+ </Text>
152
+ <Text selectable style={[typography.body, { color: colors.foreground, flex: 1 }]}>
153
+ {renderInlineSpans(item, colors, `li${index}-${itemIndex}`, query)}
154
+ </Text>
155
+ </Row>
156
+ ))}
157
+ </Stack>
158
+ );
159
+ }
160
+ return (
161
+ <Text
162
+ key={index}
163
+ selectable
164
+ style={[typography.body, { color: colors.foreground, lineHeight: 20 }]}
165
+ >
166
+ {renderInlineSpans(block.spans, colors, `p${index}`, query)}
167
+ </Text>
168
+ );
169
+ })}
170
+ </Stack>
171
+ );
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // AgentEnvelopeCard
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export function AgentEnvelopeCard({ envelope }: { envelope: AgentEnvelope }) {
179
+ const openSession = () => {
180
+ const link = envelope.paseoLinks[0];
181
+ if (!link) return;
182
+ Linking.openURL(link).catch(() => {
183
+ copyToClipboard(link).catch(() => {});
184
+ });
185
+ };
186
+
187
+ return (
188
+ <Card variant="tinted" style={styles.envelopeCard}>
189
+ <Card.Header
190
+ title={envelope.sessionTitle}
191
+ subtitle={envelope.postedAt ?? undefined}
192
+ badge={<Badge variant="neutral" label={envelope.agentShortId} />}
193
+ icon="Bot"
194
+ />
195
+ <KeyValueGroup columns={2}>
196
+ <KeyValue label="Model" value={envelope.model ?? "-"} mono />
197
+ <KeyValue
198
+ label="Branch"
199
+ value={envelope.branch ? `${envelope.repo ?? ""}:${envelope.branch}` : "-"}
200
+ mono
201
+ copyable={Boolean(envelope.branch)}
202
+ />
203
+ </KeyValueGroup>
204
+ {envelope.commitShas.length > 0 ? (
205
+ <Stack gap="xxs" style={styles.shaList}>
206
+ {envelope.commitShas.map((sha) => (
207
+ <CommandBox key={sha} command={sha} copyLabel={`Copy commit ${sha}`} />
208
+ ))}
209
+ </Stack>
210
+ ) : null}
211
+ {envelope.paseoLinks.length > 0 ? (
212
+ <Button
213
+ size="sm"
214
+ variant="ghost"
215
+ icon="ExternalLink"
216
+ label="Open agent session"
217
+ onPress={openSession}
218
+ />
219
+ ) : null}
220
+ </Card>
221
+ );
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // CommentCard
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export function CommentCard({
229
+ comment,
230
+ issueUrl,
231
+ query,
232
+ }: {
233
+ comment: IssueComment;
234
+ issueUrl?: string;
235
+ query?: string;
236
+ }) {
237
+ const body = stripAgentEnvelopeFooter(comment.body) || comment.body;
238
+ const target = comment.url || issueUrl || "";
239
+ const open = () => {
240
+ if (target) Linking.openURL(target).catch(() => {});
241
+ };
242
+
243
+ return (
244
+ <Card variant="flat" style={styles.commentCard}>
245
+ <Card.Header
246
+ title={comment.author}
247
+ subtitle={formatTimestamp(comment.createdAt)}
248
+ icon="MessageSquare"
249
+ />
250
+ <Stack gap="xs" style={styles.commentContent}>
251
+ <MarkdownLite body={body} query={query} />
252
+ {target ? (
253
+ <Row justify="flex-end" align="center" gap="xs">
254
+ <Button
255
+ size="sm"
256
+ variant="ghost"
257
+ icon="ExternalLink"
258
+ label="Open comment"
259
+ accessibilityLabel={`Open comment by ${comment.author}`}
260
+ onPress={open}
261
+ />
262
+ <CopyButton text={target} label="Copy" accessibilityLabel="Copy comment link" />
263
+ </Row>
264
+ ) : null}
265
+ </Stack>
266
+ {comment.envelope ? <AgentEnvelopeCard envelope={comment.envelope} /> : null}
267
+ </Card>
268
+ );
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // ScopedLabelGroup
273
+ // ---------------------------------------------------------------------------
274
+
275
+ export function ScopedLabelGroup({
276
+ title,
277
+ labels,
278
+ active,
279
+ pending = false,
280
+ onSelect,
281
+ }: {
282
+ title: string;
283
+ labels: readonly ForgeLabel[];
284
+ active: string | null;
285
+ pending?: boolean;
286
+ onSelect: (label: string) => void;
287
+ }) {
288
+ const { colors, typography } = usePluginTheme();
289
+
290
+ return (
291
+ <Stack gap="xxs" style={styles.labelGroup}>
292
+ <Text style={[typography.caption, { color: colors.foregroundMuted, fontWeight: "600" }]}>
293
+ {title}
294
+ </Text>
295
+ <Row wrap gap="xxs">
296
+ {labels.map((label) => {
297
+ const isSelected = active === label.name;
298
+ return (
299
+ <Button
300
+ key={label.name}
301
+ onPress={() => onSelect(label.name)}
302
+ disabled={pending}
303
+ accessibilityLabel={shortLabelName(label.name)}
304
+ size="sm"
305
+ variant={isSelected ? "primary" : "ghost"}
306
+ label={shortLabelName(label.name)}
307
+ />
308
+ );
309
+ })}
310
+ </Row>
311
+ </Stack>
312
+ );
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // NewIssueComposer
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export function NewIssueComposer({
320
+ directory,
321
+ remoteUrl,
322
+ access,
323
+ onCreated,
324
+ onOpenSettings,
325
+ }: {
326
+ directory?: string;
327
+ remoteUrl?: string;
328
+ access?: ForgeAccessState;
329
+ onCreated?: (number?: number) => void;
330
+ onOpenSettings?: () => void;
331
+ }) {
332
+ const { colors, typography } = usePluginTheme();
333
+ const { useToast } = getClientHost();
334
+ const toast = useToast();
335
+ const [expanded, setExpanded] = useState(false);
336
+ const [title, setTitle] = useState("");
337
+ const [body, setBody] = useState("");
338
+ const [labelsText, setLabelsText] = useState("");
339
+
340
+ const create = useRpcMutation(createIssueContract, {
341
+ onSuccess: (result) => {
342
+ if (result.error) {
343
+ toast?.error?.(result.error);
344
+ return;
345
+ }
346
+ setTitle("");
347
+ setBody("");
348
+ setLabelsText("");
349
+ setExpanded(false);
350
+ onCreated?.(result.number ?? undefined);
351
+ toast?.show?.(result.number ? `Created issue #${result.number}` : "Issue created", {
352
+ variant: "success",
353
+ });
354
+ },
355
+ onError: (error) => {
356
+ toast?.error?.(error instanceof Error ? error.message : "Could not create issue");
357
+ },
358
+ });
359
+
360
+ if (access && access.auth === "anonymous") return null;
361
+ if (access && !access.canEdit) {
362
+ return (
363
+ <Card variant="flat" style={styles.readOnlyCard}>
364
+ <Text style={[typography.caption, { color: colors.foregroundMuted }]}>
365
+ {writeGateNotice(access, "creating issues")}
366
+ </Text>
367
+ {onOpenSettings && (
368
+ <Button
369
+ size="sm"
370
+ variant="secondary"
371
+ icon="Settings"
372
+ label="Add a token"
373
+ onPress={onOpenSettings}
374
+ />
375
+ )}
376
+ </Card>
377
+ );
378
+ }
379
+
380
+ const pending = create.isPending;
381
+ const canSubmit = title.trim().length > 0 && !pending;
382
+
383
+ const submit = () => {
384
+ const nextTitle = title.trim();
385
+ if (!nextTitle) return;
386
+ create.mutate({
387
+ directory: directory ?? undefined,
388
+ remoteUrl: remoteUrl || undefined,
389
+ title: nextTitle,
390
+ body: body.trim(),
391
+ labels: parseLabelList(labelsText),
392
+ });
393
+ };
394
+
395
+ return (
396
+ <Collapsible
397
+ title="New ticket"
398
+ subtitle="Open a ticket on the active forge"
399
+ icon="Plus"
400
+ isExpanded={expanded}
401
+ onToggle={setExpanded}
402
+ >
403
+ <Stack gap="xs" style={styles.composerForm}>
404
+ <FormRow label="Title">
405
+ <TextInput
406
+ value={title}
407
+ onChangeText={setTitle}
408
+ placeholder="Short, specific title"
409
+ autoCapitalize="sentences"
410
+ />
411
+ </FormRow>
412
+ <FormRow label="Description" description="Markdown supported.">
413
+ <TextInput
414
+ value={body}
415
+ onChangeText={setBody}
416
+ placeholder="What needs to happen?"
417
+ multiline
418
+ numberOfLines={4}
419
+ />
420
+ </FormRow>
421
+ <Collapsible title="Labels" subtitle="Optional — comma-separated names" icon="Tags">
422
+ <FormRow label="Label names">
423
+ <TextInput
424
+ value={labelsText}
425
+ onChangeText={setLabelsText}
426
+ placeholder="state/1-wip, priority/1-high"
427
+ />
428
+ </FormRow>
429
+ </Collapsible>
430
+ <ActionBar>
431
+ <Button
432
+ label="Cancel"
433
+ variant="ghost"
434
+ disabled={pending}
435
+ onPress={() => setExpanded(false)}
436
+ />
437
+ <Button
438
+ label={pending ? "Creating…" : "Create ticket"}
439
+ variant="primary"
440
+ icon="Plus"
441
+ disabled={!canSubmit}
442
+ loading={pending}
443
+ onPress={submit}
444
+ />
445
+ </ActionBar>
446
+ </Stack>
447
+ </Collapsible>
448
+ );
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // TicketLifecycleView
453
+ // ---------------------------------------------------------------------------
454
+
455
+ export interface TicketLifecycleViewProps {
456
+ issueNumber: number;
457
+ workspaceId?: string;
458
+ directory?: string;
459
+ remoteUrl?: string;
460
+ repo?: string | null;
461
+ query?: string;
462
+ boardLabels?: Map<string, ForgeLabel>;
463
+ onRefresh?: () => void;
464
+ onOpenSettings?: () => void;
465
+ children?: React.ReactNode;
466
+ }
467
+
468
+ export function TicketLifecycleView({
469
+ issueNumber,
470
+ workspaceId: _workspaceId,
471
+ directory,
472
+ remoteUrl,
473
+ repo: _repo,
474
+ query = "",
475
+ boardLabels,
476
+ onRefresh,
477
+ onOpenSettings: _onOpenSettings,
478
+ children,
479
+ }: TicketLifecycleViewProps) {
480
+ const { colors, typography } = usePluginTheme();
481
+ const { useToast } = getClientHost();
482
+ const toast = useToast();
483
+
484
+ const baseInput = {
485
+ issueNumber,
486
+ directory: directory ?? undefined,
487
+ remoteUrl: remoteUrl || undefined,
488
+ };
489
+
490
+ const detail = useRpcQuery(
491
+ issueDetailContract,
492
+ baseInput,
493
+ { refetchInterval: 30000 },
494
+ );
495
+
496
+ const [commentDraft, setCommentDraft] = useState("");
497
+
498
+ const setLabel = useRpcMutation(setLabelContract, {
499
+ onSuccess: (result) => {
500
+ if (result.error) {
501
+ toast?.error?.(result.error);
502
+ return;
503
+ }
504
+ detail.refetch();
505
+ onRefresh?.();
506
+ },
507
+ onError: (error) => {
508
+ toast?.error?.(error instanceof Error ? error.message : "Could not update labels");
509
+ },
510
+ });
511
+
512
+ const addComment = useRpcMutation(addCommentContract, {
513
+ onSuccess: (result) => {
514
+ if (result.error) {
515
+ toast?.error?.(result.error);
516
+ return;
517
+ }
518
+ setCommentDraft("");
519
+ detail.refetch();
520
+ onRefresh?.();
521
+ toast?.show?.("Comment posted", { variant: "success" });
522
+ },
523
+ onError: (error) => {
524
+ toast?.error?.(error instanceof Error ? error.message : "Could not post comment");
525
+ },
526
+ });
527
+
528
+ const issue = detail.data && !detail.data.error ? detail.data.issue : null;
529
+ const labels = issue?.labels ?? [];
530
+ const state = currentStateLabel(labels);
531
+ const priority = issue ? currentPriorityLabel(labels) : null;
532
+ const next = issue ? nextStateLabel(labels) : null;
533
+ const labelPending = setLabel.isPending;
534
+ const commentPending = addComment.isPending;
535
+
536
+ const candidates = useMemo(() => {
537
+ const known = new Map(boardLabels ?? new Map());
538
+ for (const label of issue?.labelDetails ?? []) known.set(label.name, label);
539
+ return {
540
+ state: editorLabelChips(STATE_ORDER, known),
541
+ priority: editorLabelChips(PRIORITY_ORDER, known),
542
+ attention: editorLabelChips(ATTENTION_LABELS, known),
543
+ spec: editorLabelChips(SPEC_LABELS, known),
544
+ };
545
+ }, [boardLabels, issue?.labelDetails]);
546
+
547
+ const handleSelectLabel = (labelName: string) => {
548
+ setLabel.mutate({
549
+ issueNumber,
550
+ directory: directory ?? undefined,
551
+ remoteUrl: remoteUrl || undefined,
552
+ label: labelName,
553
+ });
554
+ };
555
+
556
+ const handlePostComment = () => {
557
+ const text = commentDraft.trim();
558
+ if (!text) return;
559
+ addComment.mutate({
560
+ issueNumber,
561
+ directory: directory ?? undefined,
562
+ remoteUrl: remoteUrl || undefined,
563
+ body: text,
564
+ });
565
+ };
566
+
567
+ return (
568
+ <Stack gap="sm" style={styles.lifecycleContainer}>
569
+ {detail.isLoading && !detail.data ? (
570
+ <Text style={[typography.caption, { color: colors.foregroundMuted }]}>
571
+ Loading ticket #{issueNumber}…
572
+ </Text>
573
+ ) : null}
574
+
575
+ {detail.data?.error || (!detail.isLoading && !issue) ? (
576
+ <EmptyState
577
+ icon="AlertCircle"
578
+ title={`Ticket #${issueNumber} unavailable`}
579
+ description={detail.data?.error ?? "Could not reach forge host."}
580
+ actionLabel="Retry"
581
+ onAction={() => detail.refetch()}
582
+ />
583
+ ) : null}
584
+
585
+ {/* Label Management: Next state promotion & Scoped label groups */}
586
+ <Stack gap="xs" style={styles.sectionBlock}>
587
+ {next && (
588
+ <Row align="center" gap="xs">
589
+ <Button
590
+ size="sm"
591
+ variant="secondary"
592
+ icon="ArrowRight"
593
+ label={`Move to ${shortLabelName(next).toLowerCase()}`}
594
+ loading={labelPending}
595
+ disabled={labelPending}
596
+ onPress={() => handleSelectLabel(next)}
597
+ />
598
+ </Row>
599
+ )}
600
+
601
+ <ScopedLabelGroup
602
+ title="Workflow State"
603
+ labels={candidates.state}
604
+ active={state}
605
+ pending={labelPending}
606
+ onSelect={handleSelectLabel}
607
+ />
608
+
609
+ <ScopedLabelGroup
610
+ title="Priority"
611
+ labels={candidates.priority}
612
+ active={priority}
613
+ pending={labelPending}
614
+ onSelect={handleSelectLabel}
615
+ />
616
+
617
+ <ScopedLabelGroup
618
+ title="Attention"
619
+ labels={candidates.attention}
620
+ active={labels.find((l: string) => (ATTENTION_LABELS as readonly string[]).includes(l)) ?? null}
621
+ pending={labelPending}
622
+ onSelect={handleSelectLabel}
623
+ />
624
+
625
+ <ScopedLabelGroup
626
+ title="Spec"
627
+ labels={candidates.spec}
628
+ active={labels.find((l: string) => (SPEC_LABELS as readonly string[]).includes(l)) ?? null}
629
+ pending={labelPending}
630
+ onSelect={handleSelectLabel}
631
+ />
632
+ </Stack>
633
+
634
+ {/* Embedded children (e.g. Worktree branch KeyValue, Close, Dispatch buttons) */}
635
+ {children}
636
+
637
+ {/* Ticket Description */}
638
+ {issue?.body ? (
639
+ <Card variant="flat" style={styles.bodyCard}>
640
+ <Card.Header title="Description" icon="FileText" />
641
+ <View style={styles.bodyPadding}>
642
+ <MarkdownLite body={issue.body} query={query} />
643
+ </View>
644
+ </Card>
645
+ ) : null}
646
+
647
+ {/* Comments List & Adding Comments */}
648
+ <Card variant="flat" style={styles.commentsCard}>
649
+ <Card.Header
650
+ title={`Comments${issue?.comments ? ` (${issue.comments.length})` : ""}`}
651
+ icon="MessageSquare"
652
+ />
653
+ <Stack gap="xs" style={styles.commentsList}>
654
+ {issue?.comments && issue.comments.length > 0 ? (
655
+ issue.comments.map((comment: IssueComment) => (
656
+ <CommentCard
657
+ key={comment.id}
658
+ comment={comment}
659
+ issueUrl={issue.webUrl}
660
+ query={query}
661
+ />
662
+ ))
663
+ ) : (
664
+ <Text style={[typography.caption, { color: colors.foregroundMuted }]}>
665
+ No comments yet.
666
+ </Text>
667
+ )}
668
+
669
+ {/* Add Comment input */}
670
+ <Stack gap="xxs" style={styles.newCommentBox}>
671
+ <TextInput
672
+ value={commentDraft}
673
+ onChangeText={setCommentDraft}
674
+ placeholder="Write a comment…"
675
+ multiline
676
+ numberOfLines={3}
677
+ />
678
+ <Row justify="flex-end">
679
+ <Button
680
+ size="sm"
681
+ variant="primary"
682
+ icon="Send"
683
+ label={commentPending ? "Posting…" : "Post comment"}
684
+ disabled={!commentDraft.trim() || commentPending}
685
+ loading={commentPending}
686
+ onPress={handlePostComment}
687
+ />
688
+ </Row>
689
+ </Stack>
690
+ </Stack>
691
+ </Card>
692
+ </Stack>
693
+ );
694
+ }
695
+
696
+ const styles = StyleSheet.create({
697
+ markdownContainer: {
698
+ width: "100%",
699
+ },
700
+ bold: {
701
+ fontWeight: "700",
702
+ },
703
+ italic: {
704
+ fontStyle: "italic",
705
+ },
706
+ inlineCode: {
707
+ fontFamily: "monospace",
708
+ fontSize: 12,
709
+ paddingHorizontal: 4,
710
+ borderRadius: 3,
711
+ },
712
+ link: {
713
+ textDecorationLine: "underline",
714
+ },
715
+ listContainer: {
716
+ paddingLeft: 4,
717
+ },
718
+ envelopeCard: {
719
+ marginTop: 6,
720
+ },
721
+ shaList: {
722
+ marginTop: 4,
723
+ },
724
+ commentCard: {
725
+ marginVertical: 4,
726
+ },
727
+ commentContent: {
728
+ padding: 8,
729
+ },
730
+ labelGroup: {
731
+ marginVertical: 2,
732
+ },
733
+ readOnlyCard: {
734
+ padding: 8,
735
+ flexDirection: "row",
736
+ alignItems: "center",
737
+ justifyContent: "space-between",
738
+ },
739
+ composerForm: {
740
+ padding: 8,
741
+ },
742
+ lifecycleContainer: {
743
+ width: "100%",
744
+ },
745
+ sectionBlock: {
746
+ paddingVertical: 4,
747
+ },
748
+ bodyCard: {
749
+ marginVertical: 4,
750
+ },
751
+ bodyPadding: {
752
+ padding: 8,
753
+ },
754
+ commentsCard: {
755
+ marginTop: 8,
756
+ },
757
+ commentsList: {
758
+ padding: 8,
759
+ },
760
+ newCommentBox: {
761
+ marginTop: 8,
762
+ paddingTop: 8,
763
+ borderTopWidth: 1,
764
+ borderTopColor: "rgba(128, 128, 128, 0.2)",
765
+ },
766
+ });