|
17 | 17 | */ |
18 | 18 |
|
19 | 19 | import { onMount, onDestroy } from 'svelte'; |
| 20 | + import { stripAnsi } from '$lib/utils/ansiToHtml'; |
20 | 21 | import { fly, fade } from 'svelte/transition'; |
21 | 22 | import { cubicOut } from 'svelte/easing'; |
22 | 23 | import { richPaste } from '$lib/actions/richPaste'; |
|
409 | 410 | let output = $state(''); |
410 | 411 | let pollInterval: ReturnType<typeof setInterval> | null = null; |
411 | 412 |
|
| 413 | + const detectedResumeSessionId = $derived.by((): string | null => { |
| 414 | + if (!output) return null; |
| 415 | + const recentOutput = stripAnsi(output.slice(-2000)); |
| 416 | + const match = recentOutput.match( |
| 417 | + /Resume this session with:\s*\nclaude --resume ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i |
| 418 | + ); |
| 419 | + return match ? match[1] : null; |
| 420 | + }); |
| 421 | +
|
412 | 422 | // Full task detail (fetched from API) |
413 | 423 | let fullTask = $state<Record<string, any> | null>(null); |
414 | 424 | let taskLoading = $state(false); |
415 | 425 | let commentsCount = $state(0); |
416 | 426 | let hasPendingQuestion = $state(false); |
417 | 427 |
|
| 428 | + // Fetch full task when on Detail or Timeline — covers deep-link opens |
| 429 | + // where navigateToPage() is never called (initialPage = 'Detail') |
| 430 | + $effect(() => { |
| 431 | + if (logicalPage !== 'Terminal' && task?.id && !fullTask) { |
| 432 | + fetchTaskDetail(); |
| 433 | + } |
| 434 | + }); |
| 435 | +
|
418 | 436 | // Dynamic page order: when agent has a pending question, Timeline moves to position 1 |
419 | 437 | const pageOrder = $derived.by((): LogicalPage[] => |
420 | 438 | hasPendingQuestion ? ['Terminal', 'Timeline', 'Detail'] : ['Terminal', 'Detail', 'Timeline'] |
|
1074 | 1092 | } |
1075 | 1093 |
|
1076 | 1094 | let copiedAttachmentId = $state<string | null>(null); |
| 1095 | + let copiedPageUrl = $state(false); |
| 1096 | +
|
| 1097 | + function copyPageUrl() { |
| 1098 | + const url = fullTask?.page_url; |
| 1099 | + if (!url) return; |
| 1100 | + navigator.clipboard.writeText(url).then(() => { |
| 1101 | + copiedPageUrl = true; |
| 1102 | + setTimeout(() => (copiedPageUrl = false), 1500); |
| 1103 | + }).catch(() => {}); |
| 1104 | + } |
| 1105 | +
|
1077 | 1106 | function copyAttachmentPath(attachment: any) { |
1078 | 1107 | const path = attachment?.path || attachment?.name || attachment?.filename; |
1079 | 1108 | if (!path) return; |
|
1528 | 1557 | } |
1529 | 1558 | loadHistory(); |
1530 | 1559 |
|
| 1560 | + // Fetch output immediately on mount so state-detection derived values |
| 1561 | + // (like detectedResumeSessionId) are populated without waiting for resize. |
| 1562 | + fetchOutput(); |
| 1563 | +
|
1531 | 1564 | // Resize tmux pane to match viewport width, then fetch output so the |
1532 | 1565 | // first render reflects the new column count (not the old narrow width). |
1533 | 1566 | // The 300ms delay gives Claude Code time to redraw after SIGWINCH. |
|
1748 | 1781 | </div> |
1749 | 1782 |
|
1750 | 1783 | <!-- Mobile Action Buttons Row (dynamic from state actions config) --> |
1751 | | - {#if stateActions.length > 0} |
| 1784 | + {#if stateActions.length > 0 || detectedResumeSessionId} |
1752 | 1785 | <div class="action-pills-wrapper bg-base-200 border-t border-base-300 flex-shrink-0"> |
1753 | 1786 | <div class="flex gap-1.5 px-2 py-1.5 overflow-x-auto"> |
| 1787 | + {#if detectedResumeSessionId} |
| 1788 | + <button |
| 1789 | + use:directClick={async () => { |
| 1790 | + if (!onSendInput) return; |
| 1791 | + await onSendInput('ctrl-u', 'key'); |
| 1792 | + await onSendInput(`claude --resume ${detectedResumeSessionId} --dangerously-skip-permissions`, 'text'); |
| 1793 | + await onSendInput('enter', 'key'); |
| 1794 | + }} |
| 1795 | + class="flex items-center gap-1 px-2 py-[0.3rem] text-[0.6875rem] font-medium rounded-md whitespace-nowrap cursor-pointer flex-shrink-0 border transition-colors active:brightness-125" |
| 1796 | + style="background: oklch(0.28 0.14 260 / 0.8); border-color: oklch(0.55 0.18 260 / 0.5); color: oklch(0.92 0.08 260);" |
| 1797 | + title={`Resume: claude --resume ${detectedResumeSessionId} --dangerously-skip-permissions`} |
| 1798 | + > |
| 1799 | + <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" width="14" height="14"> |
| 1800 | + <path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" /> |
| 1801 | + </svg> |
| 1802 | + <span>Resume</span> |
| 1803 | + </button> |
| 1804 | + {/if} |
1754 | 1805 | {#each stateActions as action (action.id)} |
1755 | 1806 | {@const isDestructive = DESTRUCTIVE_ACTIONS.has(action.id)} |
1756 | 1807 | <button |
|
2048 | 2099 | </div> |
2049 | 2100 | {/if} |
2050 | 2101 |
|
| 2102 | + <!-- Feedback Context (JST app feedback: page URL, recording, selected elements) --> |
| 2103 | + {#if fullTask?.page_url || fullTask?.recording_url || fullTask?.selected_elements?.length} |
| 2104 | + <div role="region" aria-label="Feedback Context" class="mb-4 rounded-lg overflow-hidden" style="border: 1px solid oklch(0.70 0.18 200 / 0.35); background: oklch(0.16 0.02 200);"> |
| 2105 | + <div class="px-3 py-2.5 flex flex-col gap-2"> |
| 2106 | + <div class="text-[10px] font-mono uppercase tracking-widest text-base-content/30">Feedback Context</div> |
| 2107 | + {#if fullTask.recording_url} |
| 2108 | + {@const replayBase = (() => { try { return new URL(fullTask.page_url || '').origin; } catch { return ''; } })()} |
| 2109 | + {@const replayUrl = fullTask.db_id && replayBase ? `${replayBase}/feedback/replay?id=${fullTask.db_id}` : fullTask.recording_url || ''} |
| 2110 | + {#if replayUrl} |
| 2111 | + <a |
| 2112 | + href={replayUrl} |
| 2113 | + target="_blank" |
| 2114 | + rel="noopener noreferrer" |
| 2115 | + class="feedback-recording-cta inline-flex items-center gap-2 self-start px-3 py-1.5 rounded-full text-xs font-medium" |
| 2116 | + > |
| 2117 | + <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> |
| 2118 | + View Recording |
| 2119 | + </a> |
| 2120 | + {/if} |
| 2121 | + {/if} |
| 2122 | + {#if fullTask.page_url} |
| 2123 | + <div class="flex items-center gap-1 group/pageurl"> |
| 2124 | + <a |
| 2125 | + href={fullTask.page_url} |
| 2126 | + target="_blank" |
| 2127 | + rel="noopener noreferrer" |
| 2128 | + class="feedback-page-url inline-flex items-center gap-1.5 flex-1 min-w-0 rounded px-1 py-0.5 -mx-1 -my-0.5 transition-colors" |
| 2129 | + aria-label="Open page: {fullTask.page_url}" |
| 2130 | + title={fullTask.page_url} |
| 2131 | + > |
| 2132 | + <svg class="w-3 h-3 flex-shrink-0 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg> |
| 2133 | + <span class="text-xs text-base-content/50 group-hover/pageurl:text-base-content/80 truncate transition-colors">{fullTask.page_url}</span> |
| 2134 | + </a> |
| 2135 | + <button |
| 2136 | + type="button" |
| 2137 | + class="opacity-0 group-hover/pageurl:opacity-100 transition-opacity flex-shrink-0 p-0.5 rounded hover:bg-base-300/50" |
| 2138 | + use:directClick={copyPageUrl} |
| 2139 | + title={copiedPageUrl ? 'Copied!' : 'Copy URL'} |
| 2140 | + aria-label="Copy page URL" |
| 2141 | + > |
| 2142 | + {#if copiedPageUrl} |
| 2143 | + <svg class="w-3 h-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| 2144 | + {:else} |
| 2145 | + <svg class="w-3 h-3 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"/></svg> |
| 2146 | + {/if} |
| 2147 | + </button> |
| 2148 | + </div> |
| 2149 | + {/if} |
| 2150 | + {#if fullTask.selected_elements?.length} |
| 2151 | + <div class="flex flex-col gap-1 mt-0.5"> |
| 2152 | + {#each fullTask.selected_elements as el} |
| 2153 | + {#if el.tagName || el.textContent?.trim()} |
| 2154 | + <div class="rounded bg-base-300/40 px-2 py-1 text-xs flex items-center gap-2"> |
| 2155 | + {#if el.tagName} |
| 2156 | + <span class="badge badge-xs badge-ghost font-mono uppercase flex-shrink-0">{el.tagName}</span> |
| 2157 | + {/if} |
| 2158 | + {#if el.textContent?.trim()} |
| 2159 | + <span class="text-base-content/50 truncate">"{el.textContent.trim().slice(0, 80)}"</span> |
| 2160 | + {/if} |
| 2161 | + </div> |
| 2162 | + {/if} |
| 2163 | + {/each} |
| 2164 | + </div> |
| 2165 | + {/if} |
| 2166 | + {#if fullTask.user_agent} |
| 2167 | + {@const ua = fullTask.user_agent} |
| 2168 | + {@const browserMatch = ua.match(/Chrome\/(\d+)|Firefox\/(\d+)|Safari\/(\d+)/)} |
| 2169 | + {@const osMatch = ua.match(/Mac OS X|Windows NT|Linux|Android|iPhone|iPad/)} |
| 2170 | + {@const browserStr = browserMatch |
| 2171 | + ? (ua.includes('Chrome') ? `Chrome ${browserMatch[1]}` : ua.includes('Firefox') ? `Firefox ${browserMatch[2]}` : `Safari ${browserMatch[3]}`) |
| 2172 | + : ua.slice(0, 40)} |
| 2173 | + {@const osStr = osMatch |
| 2174 | + ? (ua.includes('Mac OS X') ? 'macOS' : ua.includes('Windows') ? 'Windows' : ua.includes('Linux') ? 'Linux' : ua.includes('iPhone') ? 'iPhone' : ua.includes('iPad') ? 'iPad' : 'Android') |
| 2175 | + : ''} |
| 2176 | + <span class="text-xs font-mono text-base-content/30">{browserStr}{osStr ? ` / ${osStr}` : ''}</span> |
| 2177 | + {/if} |
| 2178 | + </div> |
| 2179 | + </div> |
| 2180 | + {/if} |
| 2181 | + |
2051 | 2182 | <div |
2052 | 2183 | role="button" |
2053 | 2184 | tabindex="0" |
|
2519 | 2650 | </div> |
2520 | 2651 |
|
2521 | 2652 | <style> |
| 2653 | + /* Feedback Context: recording CTA button */ |
| 2654 | + .feedback-recording-cta { |
| 2655 | + background: oklch(0.70 0.18 200 / 0.15); |
| 2656 | + color: oklch(0.75 0.18 200); |
| 2657 | + border: 1px solid oklch(0.70 0.18 200 / 0.30); |
| 2658 | + transition: background 150ms ease; |
| 2659 | + } |
| 2660 | + .feedback-recording-cta:hover { |
| 2661 | + background: oklch(0.70 0.18 200 / 0.25); |
| 2662 | + } |
| 2663 | + .feedback-recording-cta:focus-visible { |
| 2664 | + outline: 2px solid oklch(0.70 0.18 200 / 0.70); |
| 2665 | + outline-offset: 2px; |
| 2666 | + } |
| 2667 | +
|
| 2668 | + /* Feedback Context: page URL link */ |
| 2669 | + .feedback-page-url:hover { |
| 2670 | + background: oklch(0.25 0.01 250 / 0.40); |
| 2671 | + } |
| 2672 | + .feedback-page-url:focus-visible { |
| 2673 | + outline: 2px solid oklch(0.70 0.18 200 / 0.70); |
| 2674 | + outline-offset: 1px; |
| 2675 | + } |
| 2676 | +
|
2522 | 2677 | .mic-recording { |
2523 | 2678 | animation: mic-pulse 1.4s ease-in-out infinite; |
2524 | 2679 | } |
|
0 commit comments