csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

fix pr and issue comments, make pr history like upstreams, make reviewed checkmarks usable

dawn (May 30, 2026, 6:17 AM +0300) 714ddf93 a058612b

+713 -124
+172 -24
src/components/repo.tsx
··· 5 5 Check, 6 6 ChevronRight, 7 7 Circle, 8 + CircleCheck, 8 9 Columns2, 9 10 Copy, 10 11 Download, ··· 27 28 } from 'lucide-solid'; 28 29 import { marked } from 'marked'; 29 30 import { A } from '@solidjs/router'; 30 - import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 31 + import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 31 32 import type { RepoContext, TreeEntry } from '../lib/api/repos'; 32 33 import type { Did } from '@atcute/lexicons/syntax'; 33 34 import { createQuery } from '@tanstack/solid-query'; ··· 1434 1435 } 1435 1436 }; 1436 1437 1437 - const DiffFileTreeNodes: Component<{ nodes: DiffFileTreeNode[] }> = (props) => ( 1438 + const DiffFileTreeNodes: Component<{ 1439 + nodes: DiffFileTreeNode[]; 1440 + reviewedFiles?: () => Set<string>; 1441 + }> = (props) => ( 1438 1442 <div class="untangled-pr-file-tree-list"> 1439 - <For each={props.nodes}>{(node) => <DiffFileTreeNodeView node={node} />}</For> 1443 + <For each={props.nodes}> 1444 + {(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />} 1445 + </For> 1440 1446 </div> 1441 1447 ); 1442 1448 1443 - const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode }> = (props) => ( 1449 + const DiffFileTreeNodeView: Component<{ 1450 + node: DiffFileTreeNode; 1451 + reviewedFiles?: () => Set<string>; 1452 + }> = (props) => ( 1444 1453 <div class="untangled-pr-file-tree-node"> 1445 1454 <Show 1446 1455 when={props.node.file} ··· 1452 1461 </div> 1453 1462 <Show when={props.node.children?.length}> 1454 1463 <div class="untangled-pr-file-tree-children"> 1455 - <DiffFileTreeNodes nodes={props.node.children!} /> 1464 + <DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} /> 1456 1465 </div> 1457 1466 </Show> 1458 1467 </> 1459 1468 } 1460 1469 > 1461 - {(file) => ( 1462 - <a 1463 - href={`#${diffFileElementId(file().path)}`} 1464 - class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" 1465 - title={file().path} 1466 - onClick={(event) => scrollToDiffFile(file().path, event)} 1467 - > 1468 - <FileText class="size-4 shrink-0" /> 1469 - <span class="truncate">{props.node.name}</span> 1470 - </a> 1471 - )} 1470 + {(file) => { 1471 + const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; 1472 + return ( 1473 + <a 1474 + href={`#${diffFileElementId(file().path)}`} 1475 + class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" 1476 + title={file().path} 1477 + onClick={(event) => scrollToDiffFile(file().path, event)} 1478 + > 1479 + <FileText class="size-4 shrink-0" /> 1480 + <span class="truncate">{props.node.name}</span> 1481 + <Show when={isReviewed()}> 1482 + <span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1"> 1483 + 1484 + </span> 1485 + </Show> 1486 + </a> 1487 + ); 1488 + }} 1472 1489 </Show> 1473 1490 </div> 1474 1491 ); ··· 1484 1501 </div> 1485 1502 ); 1486 1503 1487 - export const PullDiffView: Component<{ patch: string; roundLabel: string; history: JSX.Element }> = (props) => { 1504 + export const PullDiffView: Component<{ 1505 + patch: string; 1506 + roundLabel: string; 1507 + storageKey: string; 1508 + history: JSX.Element; 1509 + }> = (props) => { 1488 1510 const files = createMemo(() => parseDiffFiles(props.patch)); 1489 1511 const stats = createMemo(() => totalDiffStats(files())); 1490 1512 const tree = createMemo(() => diffFileTree(files())); ··· 1492 1514 const [filesVisible, setFilesVisible] = createSignal(true); 1493 1515 const [historyVisible, setHistoryVisible] = createSignal(true); 1494 1516 1517 + const loadReviewed = (): Set<string> => { 1518 + try { 1519 + const data = localStorage.getItem(props.storageKey); 1520 + if (!data) return new Set(); 1521 + const entry = JSON.parse(data); 1522 + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 1523 + } catch { 1524 + return new Set(); 1525 + } 1526 + }; 1527 + 1528 + const [reviewed, setReviewed] = createSignal<Set<string>>(new Set()); 1529 + 1530 + createEffect(() => { 1531 + setReviewed(loadReviewed()); 1532 + }); 1533 + 1534 + const saveReviewed = (newSet: Set<string>) => { 1535 + const currentFilePaths = new Set(files().map((f) => f.path)); 1536 + const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); 1537 + try { 1538 + localStorage.setItem( 1539 + props.storageKey, 1540 + JSON.stringify({ 1541 + files: filesToSave, 1542 + ts: Date.now(), 1543 + }), 1544 + ); 1545 + } catch (e) { 1546 + console.error('Failed to save reviewed files:', e); 1547 + } 1548 + }; 1549 + 1550 + const pruneStale = () => { 1551 + const now = Date.now(); 1552 + const REVIEWED_PREFIX = 'reviewed:'; 1553 + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 1554 + for (let i = 0; i < localStorage.length; i++) { 1555 + const key = localStorage.key(i); 1556 + if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { 1557 + try { 1558 + const entry = JSON.parse(localStorage.getItem(key) || '{}'); 1559 + if (!entry.ts || now - entry.ts > MAX_AGE_MS) { 1560 + localStorage.removeItem(key); 1561 + } 1562 + } catch { 1563 + localStorage.removeItem(key); 1564 + } 1565 + } 1566 + } 1567 + }; 1568 + 1569 + createEffect(() => { 1570 + if (Math.random() < 0.1) { 1571 + pruneStale(); 1572 + } 1573 + }); 1574 + 1575 + const [localOpen, setLocalOpen] = createSignal<Record<string, boolean>>({}); 1576 + const [hasSetInitial, setHasSetInitial] = createSignal(false); 1577 + 1578 + createEffect(() => { 1579 + if (files().length > 0) { 1580 + const isExpanded = expanded(); 1581 + const next: Record<string, boolean> = {}; 1582 + for (const f of files()) { 1583 + if (!hasSetInitial() && reviewed().has(f.path)) { 1584 + next[f.path] = false; 1585 + } else { 1586 + next[f.path] = isExpanded; 1587 + } 1588 + } 1589 + setLocalOpen(next); 1590 + setHasSetInitial(true); 1591 + } 1592 + }); 1593 + 1594 + const toggleReview = (path: string) => { 1595 + const nextReviewed = new Set(reviewed()); 1596 + if (nextReviewed.has(path)) { 1597 + nextReviewed.delete(path); 1598 + } else { 1599 + nextReviewed.add(path); 1600 + setLocalOpen((prev) => ({ ...prev, [path]: false })); 1601 + } 1602 + setReviewed(nextReviewed); 1603 + saveReviewed(nextReviewed); 1604 + }; 1605 + 1495 1606 return ( 1496 1607 <section class="untangled-pr-diff-breakout"> 1497 1608 <div ··· 1513 1624 <PanelLeftClose class="size-4" /> 1514 1625 </Show> 1515 1626 </button> 1516 - <DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} class="text-xs text-gray-600 dark:text-gray-400" /> 1627 + <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> 1628 + <span 1629 + class={clsx( 1630 + 'text-xs font-medium transition-colors duration-150', 1631 + reviewed().size === files().length && files().length > 0 1632 + ? 'text-green-600 dark:text-green-400' 1633 + : 'text-gray-600 dark:text-gray-400' 1634 + )} 1635 + > 1636 + {reviewed().size > 0 1637 + ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` 1638 + : `${files().length} changed file${files().length === 1 ? '' : 's'}` 1639 + } 1640 + </span> 1517 1641 <span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" /> 1518 1642 <span class="uppercase tracking-wide">diff</span> 1519 1643 <span class="rounded border border-gray-300 px-2 py-0.5 font-mono text-xs text-gray-700 dark:border-gray-600 dark:text-gray-200"> ··· 1547 1671 > 1548 1672 <Show when={filesVisible()}> 1549 1673 <aside class="untangled-pr-file-tree" aria-label="changed files"> 1550 - <DiffFileTreeNodes nodes={tree()} /> 1674 + <DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} /> 1551 1675 </aside> 1552 1676 </Show> 1553 1677 1554 1678 <div class="untangled-pr-diff-files"> 1555 1679 <For each={files()}> 1556 1680 {(file) => ( 1557 - <details id={diffFileElementId(file.path)} class="untangled-pr-diff-file" open={expanded()}> 1681 + <details 1682 + id={diffFileElementId(file.path)} 1683 + class={clsx('untangled-pr-diff-file', reviewed().has(file.path) && 'opacity-60')} 1684 + open={Boolean(localOpen()[file.path])} 1685 + onToggle={(e) => { 1686 + const target = e.currentTarget; 1687 + setLocalOpen((prev) => ({ ...prev, [file.path]: target.open })); 1688 + }} 1689 + > 1558 1690 <summary class="untangled-pr-diff-file-header"> 1559 1691 <div class="flex min-w-0 items-center gap-2"> 1560 1692 <ChevronRight class="untangled-pr-diff-chevron size-4 shrink-0" /> 1561 1693 <DiffStatPill additions={file.additions} deletions={file.deletions} /> 1562 1694 <span class="truncate text-base text-gray-100">{file.path}</span> 1563 - <Circle class="size-4 shrink-0 text-gray-500" /> 1564 1695 </div> 1565 - <label class="flex shrink-0 items-center gap-1 text-xs text-gray-400"> 1566 - <Circle class="size-4" /> 1567 - reviewed 1696 + <label 1697 + class="flex shrink-0 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" 1698 + title={reviewed().has(file.path) ? 'Mark as unreviewed' : 'Mark as reviewed'} 1699 + onClick={(e) => { 1700 + e.stopPropagation(); 1701 + }} 1702 + > 1703 + <input 1704 + type="checkbox" 1705 + class="sr-only" 1706 + checked={reviewed().has(file.path)} 1707 + onChange={() => toggleReview(file.path)} 1708 + /> 1709 + <Show 1710 + when={reviewed().has(file.path)} 1711 + fallback={<Circle class="size-4" />} 1712 + > 1713 + <CircleCheck class="size-4 text-green-600 dark:text-green-400" /> 1714 + </Show> 1715 + <span class="hidden md:inline">reviewed</span> 1568 1716 </label> 1569 1717 </summary> 1570 1718 <div class="untangled-pr-diff-ellipsis">...</div>
+23
src/index.css
··· 3597 3597 border-color: rgb(55 65 81); 3598 3598 } 3599 3599 } 3600 + 3601 + /* Override btn-flat styles to ensure correct transparency and hover background colors */ 3602 + .btn-flat { 3603 + background-color: transparent !important; 3604 + border: 1px solid transparent !important; 3605 + border-radius: 0.25rem !important; 3606 + transition: background-color 150ms ease-in-out, color 150ms ease-in-out !important; 3607 + } 3608 + 3609 + .btn-flat:hover { 3610 + background-color: rgb(229 231 235 / 0.5) !important; /* bg-gray-200/50 */ 3611 + } 3612 + 3613 + @media (prefers-color-scheme: dark) { 3614 + .btn-flat { 3615 + background-color: transparent !important; 3616 + border-color: transparent !important; 3617 + } 3618 + .btn-flat:hover { 3619 + background-color: rgb(55 65 81 / 0.5) !important; /* bg-gray-700/50 */ 3620 + } 3621 + } 3622 +
+2
src/lib/api/constants.ts
··· 20 20 export const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; 21 21 export const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 22 22 export const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 23 + export const FEED_COMMENT_COLLECTION: Nsid = 'sh.tangled.feed.comment'; 24 +
+69 -14
src/lib/api/issues.ts
··· 26 26 ISSUE_COLLECTION, 27 27 ISSUE_COMMENT_COLLECTION, 28 28 ISSUE_STATE_COLLECTION, 29 + FEED_COMMENT_COLLECTION, 29 30 } from './constants'; 30 31 import { 31 32 appviewRecordToHydrated, ··· 158 159 'sh.tangled.repo.issue:repo', 159 160 ])).length; 160 161 162 + const normalizeIssueComment = (hydrated: HydratedRecord<any>, issueUri: ResourceUri): IssueComment => { 163 + const val = hydrated.value; 164 + if (!val || typeof val !== 'object') { 165 + return hydrated as IssueComment; 166 + } 167 + const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) 168 + ? val.body.text 169 + : (typeof val.body === 'string' ? val.body : ''); 170 + 171 + const replyTo = val.replyTo?.uri ?? val.replyTo; 172 + 173 + const mappedValue: ShTangledRepoIssueComment.Main = { 174 + $type: 'sh.tangled.repo.issue.comment', 175 + body: bodyText, 176 + createdAt: val.createdAt || new Date().toISOString(), 177 + issue: val.issue ?? val.subject?.uri ?? issueUri, 178 + replyTo, 179 + references: val.references ?? (replyTo ? [replyTo] : []), 180 + }; 181 + 182 + return { 183 + ...hydrated, 184 + value: mappedValue, 185 + }; 186 + }; 187 + 161 188 const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 162 189 const issues = await listIssuesFromBacklinks(repo); 163 190 const issue = findNumberedRecord(issues, issueRef); ··· 165 192 throw new Error(`Issue not found`); 166 193 } 167 194 168 - const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); 169 - const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); 195 + const [newCommentRefs, legacyCommentRefs] = await Promise.all([ 196 + getBacklinks(issue.uri, 'sh.tangled.feed.comment:subject'), 197 + getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'), 198 + ]); 199 + 200 + const commentRefs = [...newCommentRefs]; 201 + const seenUris = new Set(commentRefs.map(r => `at://${r.did}/${r.collection}/${r.rkey}`)); 202 + for (const ref of legacyCommentRefs) { 203 + const refUri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; 204 + if (!seenUris.has(refUri)) { 205 + commentRefs.push(ref); 206 + seenUris.add(refUri); 207 + } 208 + } 209 + 210 + const hydrated = await hydrateBacklinks<any>(commentRefs); 211 + const comments = hydrated.map((record) => normalizeIssueComment(record, issue.uri)); 170 212 comments.sort(sortByCreatedAt); 171 213 172 214 return { ··· 176 218 }; 177 219 178 220 const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { 179 - const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 180 - 'sh.tangled.repo.issue.listComments', 221 + const records = await listAllAppviewRecords<any>( 222 + 'sh.tangled.feed.listComments', 181 223 issueUri, 182 224 ); 183 - const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 225 + const comments = await Promise.all(records.map(async (record) => { 226 + const hydrated = await appviewRecordToHydrated<any>(record); 227 + return normalizeIssueComment(hydrated, issueUri); 228 + })); 184 229 comments.sort(sortByCreatedAt); 185 230 return comments; 186 231 }; ··· 324 369 export const createIssueComment = async ( 325 370 agent: OAuthUserAgent, 326 371 issueUri: ResourceUri, 372 + issueCid: string, 327 373 body: string, 328 - replyTo?: ResourceUri, 374 + replyTo?: { uri: ResourceUri; cid: string }, 329 375 ): Promise<RecordCreationResult> => { 330 376 const rpc = createAuthRpc(agent); 331 - const record: ShTangledRepoIssueComment.Main = { 332 - $type: 'sh.tangled.repo.issue.comment', 333 - issue: issueUri, 334 - body: body.trim(), 377 + const record = { 378 + $type: 'sh.tangled.feed.comment', 379 + subject: { 380 + uri: issueUri, 381 + cid: issueCid, 382 + }, 383 + body: { 384 + $type: 'sh.tangled.markup.markdown', 385 + text: body.trim(), 386 + }, 335 387 createdAt: new Date().toISOString(), 336 - mentions: extractDids(body), 337 - references: extractAtUris(body), 338 - ...(replyTo ? { replyTo } : {}), 388 + ...(replyTo ? { 389 + replyTo: { 390 + uri: replyTo.uri, 391 + cid: replyTo.cid, 392 + } 393 + } : {}), 339 394 }; 340 395 341 396 const result = await ok( 342 397 rpc.post('com.atproto.repo.createRecord', { 343 398 input: { 344 399 repo: agent.sub, 345 - collection: ISSUE_COMMENT_COLLECTION, 400 + collection: FEED_COMMENT_COLLECTION, 346 401 record, 347 402 }, 348 403 }),
+68 -12
src/lib/api/pulls.ts
··· 28 28 PULL_COLLECTION, 29 29 PULL_COMMENT_COLLECTION, 30 30 PULL_STATUS_COLLECTION, 31 + FEED_COMMENT_COLLECTION, 31 32 } from './constants'; 32 33 import { createServiceAuthRpc, getRpc } from './client'; 33 34 import { ··· 183 184 throw new Error(`Pull request not found`); 184 185 } 185 186 186 - const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); 187 - const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); 187 + const [newCommentRefs, legacyCommentRefs] = await Promise.all([ 188 + getBacklinks(pull.uri, 'sh.tangled.feed.comment:subject'), 189 + getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'), 190 + ]); 191 + 192 + const commentRefs = [...newCommentRefs]; 193 + const seenUris = new Set(commentRefs.map(r => `at://${r.did}/${r.collection}/${r.rkey}`)); 194 + for (const ref of legacyCommentRefs) { 195 + const refUri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; 196 + if (!seenUris.has(refUri)) { 197 + commentRefs.push(ref); 198 + seenUris.add(refUri); 199 + } 200 + } 201 + 202 + const hydrated = await hydrateBacklinks<any>(commentRefs); 203 + const comments = hydrated.map((record) => normalizePullComment(record, pull.uri)); 188 204 comments.sort(sortByCreatedAt); 189 205 190 206 return { ··· 193 209 }; 194 210 }; 195 211 212 + const normalizePullComment = (hydrated: HydratedRecord<any>, pullUri: ResourceUri): PullComment => { 213 + const val = hydrated.value; 214 + if (!val || typeof val !== 'object') { 215 + return hydrated as PullComment; 216 + } 217 + const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) 218 + ? val.body.text 219 + : (typeof val.body === 'string' ? val.body : ''); 220 + 221 + const pullRoundIdx = val.pullRoundIdx !== undefined ? val.pullRoundIdx : val.round; 222 + const replyTo = val.replyTo?.uri ?? val.replyTo; 223 + 224 + const mappedValue: ShTangledRepoPullComment.Main = { 225 + $type: 'sh.tangled.repo.pull.comment', 226 + body: bodyText, 227 + createdAt: val.createdAt || new Date().toISOString(), 228 + pull: val.pull ?? val.subject?.uri ?? pullUri, 229 + references: val.references ?? (replyTo ? [replyTo] : []), 230 + }; 231 + 232 + return { 233 + ...hydrated, 234 + value: { 235 + ...mappedValue, 236 + replyTo, 237 + pullRoundIdx, 238 + } as any, 239 + }; 240 + }; 241 + 196 242 const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { 197 - const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 198 - 'sh.tangled.repo.pull.listComments', 243 + const records = await listAllAppviewRecords<any>( 244 + 'sh.tangled.feed.listComments', 199 245 pullUri, 200 246 ); 201 - const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 247 + const comments = await Promise.all(records.map(async (record) => { 248 + const hydrated = await appviewRecordToHydrated<any>(record); 249 + return normalizePullComment(hydrated, pullUri); 250 + })); 202 251 comments.sort(sortByCreatedAt); 203 252 return comments; 204 253 }; ··· 395 444 export const createPullComment = async ( 396 445 agent: OAuthUserAgent, 397 446 pullUri: ResourceUri, 447 + pullCid: string, 398 448 body: string, 449 + pullRoundIdx: number, 399 450 ): Promise<RecordCreationResult> => { 400 451 const rpc = createAuthRpc(agent); 401 - const record: ShTangledRepoPullComment.Main = { 402 - $type: 'sh.tangled.repo.pull.comment', 403 - pull: pullUri, 404 - body: body.trim(), 452 + const record = { 453 + $type: 'sh.tangled.feed.comment', 454 + subject: { 455 + uri: pullUri, 456 + cid: pullCid, 457 + }, 458 + body: { 459 + $type: 'sh.tangled.markup.markdown', 460 + text: body.trim(), 461 + }, 405 462 createdAt: new Date().toISOString(), 406 - mentions: extractDids(body), 407 - references: extractAtUris(body), 463 + pullRoundIdx, 408 464 }; 409 465 410 466 const result = await ok( 411 467 rpc.post('com.atproto.repo.createRecord', { 412 468 input: { 413 469 repo: agent.sub, 414 - collection: PULL_COMMENT_COLLECTION, 470 + collection: FEED_COMMENT_COLLECTION, 415 471 record, 416 472 }, 417 473 }),
+17 -11
src/pages/repo/issues.tsx
··· 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; 6 6 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 7 - import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState } from '../../lib/api/issues'; 7 + import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState, type IssueComment } from '../../lib/api/issues'; 8 8 import { resolveActor } from '../../lib/api/identity'; 9 9 import { parseAtUri } from '../../lib/api/records'; 10 10 import type { RepoContext } from '../../lib/api/repos'; ··· 304 304 const client = useQueryClient(); 305 305 const issueRef = createMemo(() => params.issueRef ?? ''); 306 306 const [comment, setComment] = createSignal(''); 307 - const [replyingTo, setReplyingTo] = createSignal<ResourceUri | null>(null); 307 + const [replyingTo, setReplyingTo] = createSignal<IssueComment | null>(null); 308 308 const [replyBody, setReplyBody] = createSignal(''); 309 309 const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null); 310 310 const working = createMemo(() => workingAction() !== null); ··· 372 372 setError(null); 373 373 try { 374 374 if (commentBody) { 375 - await createIssueComment(agent, detail.issue.uri, commentBody); 375 + await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', commentBody); 376 376 setComment(''); 377 377 } 378 378 await setIssueState(agent, detail.issue.uri, nextState); ··· 403 403 setWorkingAction('comment'); 404 404 setError(null); 405 405 try { 406 - await createIssueComment(agent, detail.issue.uri, comment()); 406 + await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', comment()); 407 407 setComment(''); 408 408 await withIssueInvalidation(repo, issueRef(), detail.issue.uri); 409 409 } catch (cause) { ··· 413 413 } 414 414 }; 415 415 416 - const submitReply = async (event: SubmitEvent, replyTo: ResourceUri) => { 416 + const submitReply = async (event: SubmitEvent, replyToComment: IssueComment) => { 417 417 event.preventDefault(); 418 418 const agent = auth.agent(); 419 419 const repo = repoQuery.data; ··· 425 425 setWorkingAction('reply'); 426 426 setError(null); 427 427 try { 428 - await createIssueComment(agent, detail.issue.uri, replyBody(), replyTo); 428 + await createIssueComment( 429 + agent, 430 + detail.issue.uri, 431 + detail.issue.cid ?? '', 432 + replyBody(), 433 + { uri: replyToComment.uri, cid: replyToComment.cid ?? '' } 434 + ); 429 435 setReplyBody(''); 430 436 setReplyingTo(null); 431 437 await withIssueInvalidation(repo, issueRef(), detail.issue.uri); ··· 528 534 </div> 529 535 530 536 <Show when={thread.replies.length > 0}> 531 - <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 537 + <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> 532 538 <For each={thread.replies}> 533 539 {(reply) => ( 534 540 <div class="-ml-4 flex gap-2 py-4 pr-4"> 535 - <div class="shrink-0"> 541 + <div class="shrink-0 relative z-10"> 536 542 <Avatar did={reply.author.did} size="size-8 mr-1" /> 537 543 </div> 538 544 <div class="min-w-0 flex-1"> ··· 554 560 555 561 <Show when={auth.agent()}> 556 562 <Show 557 - when={replyingTo() === thread.item.uri} 563 + when={replyingTo()?.uri === thread.item.uri} 558 564 fallback={ 559 565 <button 560 566 type="button" 561 567 class="flex w-full items-center gap-2 border-t border-gray-300 px-6 py-2 text-left text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400" 562 568 onClick={() => { 563 - setReplyingTo(thread.item.uri); 569 + setReplyingTo(thread.item); 564 570 setReplyBody(''); 565 571 }} 566 572 > ··· 572 578 } 573 579 > 574 580 <form 575 - onSubmit={(event) => void submitReply(event, thread.item.uri)} 581 + onSubmit={(event) => void submitReply(event, thread.item)} 576 582 class="flex w-full flex-col gap-2 border-t border-gray-200 p-2 dark:border-gray-700" 577 583 > 578 584 <Show when={currentDid()}>
+62
src/pages/repo/pulls-helpers.ts
··· 75 75 76 76 return { title: subject, body }; 77 77 }; 78 + 79 + import type { PullComment } from '../../lib/api/pulls'; 80 + 81 + export type PRCommentThread = { 82 + item: PullComment; 83 + replies: PullComment[]; 84 + }; 85 + 86 + export const getRoundIndexForComment = (commentCreatedAt: string, rounds: Array<{ createdAt: string }>): number => { 87 + if (rounds.length <= 1) return 0; 88 + const commentTime = new Date(commentCreatedAt).getTime(); 89 + 90 + for (let i = 1; i < rounds.length; i++) { 91 + const nextRoundTime = new Date(rounds[i].createdAt).getTime(); 92 + if (commentTime < nextRoundTime) { 93 + return i - 1; 94 + } 95 + } 96 + return rounds.length - 1; 97 + }; 98 + 99 + export const buildPRCommentThreads = (comments: PullComment[]): PRCommentThread[] => { 100 + const byUri = new Map(comments.map((comment) => [comment.uri, comment])); 101 + const topLevel: PullComment[] = []; 102 + const repliesByRoot = new Map<string, PullComment[]>(); 103 + 104 + const findRoot = (comment: PullComment): PullComment | null => { 105 + let current = comment; 106 + const visited = new Set<string>([comment.uri]); 107 + 108 + while ((current.value as any).replyTo) { 109 + const replyToRef = (current.value as any).replyTo; 110 + const parentUri = typeof replyToRef === 'string' ? replyToRef : (replyToRef.uri || replyToRef.URI || replyToRef.Uri); 111 + if (!parentUri) break; 112 + 113 + const parent = byUri.get(parentUri); 114 + if (!parent || visited.has(parent.uri)) { 115 + return current === comment ? null : current; 116 + } 117 + 118 + current = parent; 119 + visited.add(current.uri); 120 + } 121 + 122 + return current; 123 + }; 124 + 125 + for (const comment of comments) { 126 + const root = findRoot(comment); 127 + if (!root || root.uri === comment.uri) { 128 + topLevel.push(comment); 129 + continue; 130 + } 131 + 132 + repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]); 133 + } 134 + 135 + return topLevel.map((item) => ({ 136 + item, 137 + replies: repliesByRoot.get(item.uri) ?? [], 138 + })); 139 + };
+300 -63
src/pages/repo/pulls.tsx
··· 1 1 import clsx from 'clsx'; 2 2 import { 3 3 ArrowLeftRight, 4 + CircleChevronUp, 4 5 CirclePlus, 5 6 Copy, 7 + Diff, 6 8 Eye, 7 9 FolderCode, 8 10 GitMerge, 9 11 GitPullRequest, 10 12 GitPullRequestClosed, 11 13 LoaderCircle, 12 - MessageSquare, 14 + MessageSquarePlus, 13 15 Pencil, 14 16 RefreshCcwDot, 17 + Reply, 15 18 Search, 16 19 X, 17 20 } from 'lucide-solid'; ··· 25 28 import { compareBranches, getRepoBranches, type RepoContext, compareFork, listRepoRecords } from '../../lib/api/repos'; 26 29 import { useAuth } from '../../lib/auth'; 27 30 import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 28 - import { AtUriPanel, BranchPill, CommentCard, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 31 + import { AtUriPanel, BranchPill, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 29 32 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 30 33 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; 31 - import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode } from './pulls-helpers'; 34 + import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode, getRoundIndexForComment, buildPRCommentThreads } from './pulls-helpers'; 32 35 33 36 export const PullsPage: Component = () => { 34 37 const auth = useAuth(); ··· 1005 1008 return data; 1006 1009 }; 1007 1010 1011 + const PRCommentView: Component<{ 1012 + author: string; 1013 + did: string; 1014 + createdAt: string; 1015 + markdown: string; 1016 + isReply?: boolean; 1017 + }> = (props) => ( 1018 + <div 1019 + class={clsx( 1020 + 'flex gap-2 w-full mx-auto text-sm text-gray-900 dark:text-gray-100 py-4', 1021 + props.isReply && 'pr-4' 1022 + )} 1023 + > 1024 + <div class="shrink-0 h-fit relative z-10"> 1025 + <Avatar did={props.did} size="size-8 mr-1" /> 1026 + </div> 1027 + <div class="min-w-0 flex-1"> 1028 + <div class="flex flex-wrap items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-0.5"> 1029 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 font-medium hover:underline" href={`/${props.did}`}>{props.author}</a> 1030 + <span class="select-none">·</span> 1031 + <span>{formatRelativeTime(props.createdAt)}</span> 1032 + </div> 1033 + <div class="min-w-0 break-words text-gray-800 dark:text-gray-200"> 1034 + <MarkdownBlock markdown={props.markdown} /> 1035 + </div> 1036 + </div> 1037 + </div> 1038 + ); 1039 + 1008 1040 export const PullPage: Component = () => { 1009 1041 const auth = useAuth(); 1010 1042 const repoQuery = useRepoQuery(); ··· 1013 1045 const pullRef = createMemo(() => params.pullRef ?? ''); 1014 1046 const [roundIndex, setRoundIndex] = createSignal(0); 1015 1047 const [comment, setComment] = createSignal(''); 1048 + const [composerOpen, setComposerOpen] = createSignal(false); 1016 1049 const [workingAction, setWorkingAction] = createSignal<'comment' | 'state' | 'merge' | null>(null); 1017 1050 const working = createMemo(() => workingAction() !== null); 1018 1051 const [error, setError] = createSignal<string | null>(null); ··· 1026 1059 }; 1027 1060 }); 1028 1061 1062 + const commentsByRound = createMemo(() => { 1063 + const detailData = pullQuery.data; 1064 + if (!detailData) return {}; 1065 + const rounds = detailData.pull.value.rounds || []; 1066 + const groups: Record<number, typeof detailData.comments> = {}; 1067 + for (let i = 0; i < rounds.length; i++) { 1068 + groups[i] = []; 1069 + } 1070 + for (const comment of detailData.comments) { 1071 + const roundVal = (comment.value as any).pullRoundIdx ?? (comment.value as any).round; 1072 + const roundIdx = typeof roundVal === 'number' 1073 + ? roundVal 1074 + : getRoundIndexForComment(comment.value.createdAt, rounds); 1075 + if (!groups[roundIdx]) { 1076 + groups[roundIdx] = []; 1077 + } 1078 + groups[roundIdx].push(comment); 1079 + } 1080 + return groups; 1081 + }); 1082 + 1083 + 1029 1084 createEffect(() => { 1030 1085 const rounds = pullQuery.data?.pull.value.rounds.length ?? 0; 1031 1086 if (rounds > 0) { ··· 1163 1218 setWorkingAction('comment'); 1164 1219 setError(null); 1165 1220 try { 1166 - await createPullComment(agent, detail.pull.uri, comment()); 1221 + await createPullComment( 1222 + agent, 1223 + detail.pull.uri, 1224 + detail.pull.cid ?? '', 1225 + comment(), 1226 + detail.pull.value.rounds.length - 1, 1227 + ); 1167 1228 setComment(''); 1229 + setComposerOpen(false); 1168 1230 await invalidate(repo, pullRef(), detail.pull.uri); 1169 1231 } catch (cause) { 1170 1232 setError(cause instanceof Error ? cause.message : 'Failed to create comment'); ··· 1327 1389 <PullDiffView 1328 1390 patch={patchQuery.data!} 1329 1391 roundLabel={roundLabel()} 1392 + storageKey={`reviewed:${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}/pulls/${detail().pull.number || parseAtUri(detail().pull.uri).rkey}/round/${roundIndex()}`} 1330 1393 history={ 1331 - <div class="untangled-pr-history-card"> 1332 - <header> 1333 - <h2>History</h2> 1334 - <div> 1335 - <span>{detail().pull.value.rounds.length} round</span> 1336 - <span>{detail().comments.length} comments</span> 1337 - </div> 1338 - </header> 1339 - <For each={detail().pull.value.rounds}> 1340 - {(round, index) => ( 1341 - <button 1342 - type="button" 1343 - class={clsx('untangled-pr-history-submission', index() === roundIndex() && 'untangled-pr-history-submission-active')} 1344 - onClick={() => setRoundIndex(index())} 1345 - > 1346 - <Avatar did={detail().pull.author.did} size="size-8" /> 1347 - <span class="min-w-0"> 1348 - <span class="flex flex-wrap items-center gap-2 text-sm"> 1349 - <span class="font-medium">{detail().pull.author.handle}</span> 1350 - <span>submitted</span> 1351 - <span class="rounded bg-blue-600 px-2 py-0.5 font-mono text-xs text-white">#{index()}</span> 1352 - <span>·</span> 1353 - <span>{formatRelativeTime(round.createdAt)}</span> 1354 - </span> 1355 - </span> 1356 - </button> 1394 + <div class="flex flex-col"> 1395 + {/* History Header */} 1396 + <div 1397 + class={clsx( 1398 + 'flex items-baseline justify-between border border-b-0 rounded-t px-4 py-3 shadow-sm transition-colors duration-200', 1399 + roundIndex() === 0 1400 + ? 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-900' 1401 + : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700' 1357 1402 )} 1358 - </For> 1359 - <div class={clsx('untangled-pr-history-event', statusEvent().class)}> 1360 - {statusEvent().icon} 1361 - <span>{statusEvent().text}</span> 1403 + > 1404 + <h3 class="text-base font-semibold text-gray-900 dark:text-white">history</h3> 1405 + <span class="text-sm text-gray-500 dark:text-gray-400"> 1406 + {detail().pull.value.rounds.length} round{detail().pull.value.rounds.length === 1 ? '' : 's'} 1407 + <span class="mx-1 select-none">·</span> 1408 + {detail().comments.length} comment{detail().comments.length === 1 ? '' : 's'} 1409 + </span> 1362 1410 </div> 1363 - <For each={detail().comments}> 1364 - {(item) => ( 1365 - <CommentCard 1366 - author={item.author.handle} 1367 - did={item.author.did} 1368 - createdAt={item.value.createdAt} 1369 - markdown={item.value.body} 1370 - /> 1371 - )} 1411 + 1412 + <For each={detail().pull.value.rounds}> 1413 + {(round, index) => { 1414 + const isSelected = () => index() === roundIndex(); 1415 + const isLatest = () => index() === detail().pull.value.rounds.length - 1; 1416 + const roundComments = () => commentsByRound()[index()] || []; 1417 + const roundThreads = createMemo(() => buildPRCommentThreads(roundComments())); 1418 + 1419 + return ( 1420 + <div 1421 + class={clsx( 1422 + 'w-full border overflow-clip shadow-sm text-sm', 1423 + index() === 0 ? 'rounded-b' : 'rounded', 1424 + index() > 0 && 'mt-4', 1425 + isSelected() 1426 + ? 'bg-blue-50/25 border-blue-200 dark:bg-blue-900/10 dark:border-blue-900' 1427 + : 'bg-gray-50 border-gray-200 dark:bg-gray-900 dark:border-gray-700' 1428 + )} 1429 + > 1430 + {/* Submission Header */} 1431 + <div 1432 + class={clsx( 1433 + 'px-6 py-4 flex gap-2 border-b', 1434 + isSelected() 1435 + ? 'bg-blue-50 dark:bg-blue-950 border-blue-100 dark:border-blue-900' 1436 + : 'bg-white dark:bg-gray-800 border-gray-100 dark:border-gray-700' 1437 + )} 1438 + > 1439 + <div class="shrink-0"> 1440 + <Avatar did={detail().pull.author.did} size="size-8 mr-1" /> 1441 + </div> 1442 + <div class="flex-1 min-w-0 flex flex-col justify-center"> 1443 + <div class="flex items-center justify-between"> 1444 + <span 1445 + class={clsx( 1446 + 'inline-flex items-center gap-2 text-sm', 1447 + isSelected() 1448 + ? 'text-gray-600 dark:text-gray-300' 1449 + : 'text-gray-500 dark:text-gray-400' 1450 + )} 1451 + > 1452 + <a 1453 + href={`/${detail().pull.author.did}`} 1454 + class={clsx( 1455 + 'hover:underline font-medium', 1456 + isSelected() 1457 + ? 'text-gray-800 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200' 1458 + : 'text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300' 1459 + )} 1460 + > 1461 + {detail().pull.author.handle} 1462 + </a> 1463 + <span>submitted</span> 1464 + <span 1465 + class={clsx( 1466 + 'px-2 py-0.5 rounded font-mono text-xs border', 1467 + isSelected() 1468 + ? 'text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500' 1469 + : 'text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600' 1470 + )} 1471 + > 1472 + #{index()} 1473 + </span> 1474 + <span class="select-none">·</span> 1475 + <span>{formatRelativeTime(round.createdAt)}</span> 1476 + </span> 1477 + 1478 + <Show when={!isSelected()}> 1479 + <button 1480 + type="button" 1481 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline text-sm font-medium cursor-pointer" 1482 + onClick={() => setRoundIndex(index())} 1483 + > 1484 + <Diff class="size-4" /> 1485 + diff 1486 + </button> 1487 + </Show> 1488 + </div> 1489 + </div> 1490 + </div> 1491 + 1492 + {/* Comments section */} 1493 + <Show when={roundComments().length > 0}> 1494 + <details 1495 + class="relative ml-10 pr-4 group/comments mt-0" 1496 + open={isSelected()} 1497 + > 1498 + <summary class="cursor-pointer list-none select-none"> 1499 + <div class="hidden group-open/comments:block absolute -left-8 top-0 bottom-0 w-16 flex items-center justify-center pointer-events-none"> 1500 + <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700"></div> 1501 + </div> 1502 + <div class="group-open/comments:hidden block relative py-2"> 1503 + <div class="absolute -left-8 top-0 bottom-0 w-16 flex items-center justify-center pointer-events-none"> 1504 + <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700"></div> 1505 + </div> 1506 + <span class="text-gray-500 dark:text-gray-400 text-sm flex items-center gap-1.5 -ml-2 relative hover:text-gray-600 dark:hover:text-gray-300"> 1507 + <CirclePlus class="size-3.5" /> 1508 + Expand {roundComments().length} comment{roundComments().length === 1 ? '' : 's'} 1509 + </span> 1510 + </div> 1511 + </summary> 1512 + 1513 + <div class="relative border-l border-gray-200 dark:border-gray-700 pr-4 pb-2"> 1514 + <For each={roundThreads()}> 1515 + {(thread) => ( 1516 + <div class="flex flex-col -ml-4"> 1517 + <PRCommentView 1518 + author={thread.item.author.handle} 1519 + did={thread.item.author.did} 1520 + createdAt={thread.item.value.createdAt} 1521 + markdown={thread.item.value.body} 1522 + /> 1523 + <Show when={thread.replies.length > 0}> 1524 + <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> 1525 + <For each={thread.replies}> 1526 + {(reply) => ( 1527 + <div class="-ml-4"> 1528 + <PRCommentView 1529 + author={reply.author.handle} 1530 + did={reply.author.did} 1531 + createdAt={reply.value.createdAt} 1532 + markdown={reply.value.body} 1533 + isReply={true} 1534 + /> 1535 + </div> 1536 + )} 1537 + </For> 1538 + </div> 1539 + </Show> 1540 + </div> 1541 + )} 1542 + </For> 1543 + 1544 + <button 1545 + type="button" 1546 + class="flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 mt-4 pb-4 transition-colors hover:text-gray-600 dark:hover:text-gray-300" 1547 + onClick={(e) => { 1548 + const detailsEl = e.currentTarget.closest('details'); 1549 + if (detailsEl) detailsEl.open = false; 1550 + }} 1551 + > 1552 + <CircleChevronUp class="size-4" /> 1553 + Collapse comment{roundComments().length === 1 ? '' : 's'} 1554 + </button> 1555 + </div> 1556 + </details> 1557 + </Show> 1558 + 1559 + {/* Latest round status and comment form */} 1560 + <Show when={isLatest()}> 1561 + {/* State event banner */} 1562 + <Show when={detail().pull.state !== 'open'}> 1563 + <div class={clsx('untangled-pr-history-event mx-4 mb-3 mt-3 text-sm font-medium', statusEvent().class)}> 1564 + {statusEvent().icon} 1565 + <span>{statusEvent().text}</span> 1566 + </div> 1567 + </Show> 1568 + 1569 + {/* Reply/Comment Form Actions Strip */} 1570 + <Show when={auth.agent()}> 1571 + <div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-3"> 1572 + <Show when={!composerOpen()}> 1573 + <div class="flex items-center justify-start"> 1574 + <button 1575 + type="button" 1576 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline text-sm font-medium cursor-pointer" 1577 + onClick={() => setComposerOpen(true)} 1578 + > 1579 + <MessageSquarePlus class="size-4" /> 1580 + comment 1581 + </button> 1582 + </div> 1583 + </Show> 1584 + 1585 + <Show when={composerOpen()}> 1586 + <form onSubmit={addComment} class="flex flex-col gap-2 w-full"> 1587 + <textarea 1588 + value={comment()} 1589 + onInput={(event) => setComment(event.currentTarget.value)} 1590 + class={textareaStyles()} 1591 + rows="4" 1592 + placeholder="Add to the discussion..." 1593 + /> 1594 + <Show when={error()}> 1595 + <p class="text-sm text-red-500">{error()}</p> 1596 + </Show> 1597 + <div class="flex items-center justify-end gap-2 text-sm w-full"> 1598 + <button 1599 + type="button" 1600 + class="btn-flat text-red-500 dark:text-red-400 flex items-center gap-1.5 cursor-pointer font-medium" 1601 + onClick={() => { 1602 + setComment(''); 1603 + setComposerOpen(false); 1604 + }} 1605 + > 1606 + <X class="size-4" /> 1607 + <span>cancel</span> 1608 + </button> 1609 + 1610 + <button 1611 + type="submit" 1612 + class={clsx(buttonStyles('primary'), 'justify-center')} 1613 + disabled={working() || !comment().trim()} 1614 + > 1615 + <Show when={workingAction() === 'comment'} fallback={<Reply class="size-4" />}> 1616 + <LoaderCircle class="size-4 animate-spin" /> 1617 + </Show> 1618 + <span>reply</span> 1619 + </button> 1620 + </div> 1621 + </form> 1622 + </Show> 1623 + </div> 1624 + </Show> 1625 + </Show> 1626 + </div> 1627 + ); 1628 + }} 1372 1629 </For> 1373 - <Show when={auth.agent()}> 1374 - <form onSubmit={addComment} class="untangled-pr-history-comment"> 1375 - <textarea 1376 - value={comment()} 1377 - onInput={(event) => setComment(event.currentTarget.value)} 1378 - class={textareaStyles()} 1379 - rows="4" 1380 - placeholder="new comment" 1381 - /> 1382 - <Show when={error()}> 1383 - <p class="text-sm text-red-500">{error()}</p> 1384 - </Show> 1385 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> 1386 - <Show when={workingAction() === 'comment'} fallback={<MessageSquare class="size-4" />}> 1387 - <LoaderCircle class="size-4 animate-spin" /> 1388 - </Show> 1389 - comment 1390 - </button> 1391 - </form> 1392 - </Show> 1393 1630 </div> 1394 1631 } 1395 1632 />