|
235 | 235 | // Action loading state |
236 | 236 | let actionLoading = $state<string | null>(null); |
237 | 237 |
|
| 238 | + // Per-action visual feedback — tracks which button was just clicked per session |
| 239 | + // key: "sessionName:actionId", value: feedback variant ('success' | 'warning' | 'error' | 'info' | 'working') |
| 240 | + let actionFeedback = $state<Map<string, string>>(new Map()); |
| 241 | +
|
238 | 242 | // Per-session auto-complete disabled state (when user manually overrides) |
239 | 243 | let autoCompleteDisabledMap = $state<Map<string, boolean>>(new Map()); |
240 | 244 |
|
|
904 | 908 | let swipeState = $state<SwipeState | null>(null); |
905 | 909 | let swipeOffsets = $state<Map<string, number>>(new Map()); |
906 | 910 |
|
| 911 | + // Set visual feedback on a tray button, auto-clears after animation |
| 912 | + function setActionFeedback(sessionName: string, actionId: string, variant: string, durationMs = 800) { |
| 913 | + const key = `${sessionName}:${actionId}`; |
| 914 | + actionFeedback.set(key, variant); |
| 915 | + actionFeedback = new Map(actionFeedback); |
| 916 | + setTimeout(() => { |
| 917 | + actionFeedback.delete(key); |
| 918 | + actionFeedback = new Map(actionFeedback); |
| 919 | + }, durationMs); |
| 920 | + } |
| 921 | +
|
907 | 922 | async function handleMobileAction(actionId: string, sessionName: string, sessionTask: AgentTask | null, agentName: string, project: string | null) { |
| 923 | + // Prevent double-clicks while feedback is active |
| 924 | + const feedbackKey = `${sessionName}:${actionId}`; |
| 925 | + if (actionFeedback.has(feedbackKey)) return; |
| 926 | +
|
908 | 927 | if (actionId === 'attach') { |
| 928 | + setActionFeedback(sessionName, actionId, 'info'); |
909 | 929 | await handleAttachSession(sessionName); |
910 | 930 | } else if (actionId === 'kill' || actionId === 'cleanup') { |
| 931 | + setActionFeedback(sessionName, actionId, 'error', 1200); |
911 | 932 | await handleKillSession(sessionName); |
912 | 933 | } else if (actionId === 'view-task' && sessionTask) { |
913 | 934 | onViewTask?.(sessionTask.id); |
914 | 935 | } else if (actionId === 'complete' || actionId === 'complete-kill') { |
| 936 | + setActionFeedback(sessionName, actionId, 'success', 1500); |
915 | 937 | optimisticStates.set(sessionName, 'completing'); |
916 | 938 | optimisticStates = new Map(optimisticStates); |
917 | 939 | if (sessionTask) { |
|
924 | 946 | } |
925 | 947 | await sendWorkflowCommand(sessionName, actionId === 'complete-kill' ? '/jat:complete --kill' : '/jat:complete'); |
926 | 948 | } else if (actionId === 'interrupt') { |
| 949 | + setActionFeedback(sessionName, actionId, 'warning'); |
927 | 950 | await fetch(`/api/work/${encodeURIComponent(sessionName)}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'ctrl-c' }) }); |
928 | 951 | } else if (actionId === 'escape') { |
| 952 | + setActionFeedback(sessionName, actionId, 'warning'); |
929 | 953 | await fetch(`/api/work/${encodeURIComponent(sessionName)}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'escape' }) }); |
930 | 954 | } else if (actionId === 'close-kill') { |
| 955 | + setActionFeedback(sessionName, actionId, 'error', 1200); |
931 | 956 | if (sessionTask) { |
932 | 957 | try { await fetch(`/api/tasks/${encodeURIComponent(sessionTask.id)}/close`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'Abandoned via Close & Kill' }) }); } catch (e) { console.warn('[TasksActive] close task failed:', e); } |
933 | 958 | } |
934 | 959 | await handleKillSession(sessionName); |
935 | 960 | } else if (actionId === 'pause') { |
| 961 | + setActionFeedback(sessionName, actionId, 'info', 1200); |
936 | 962 | optimisticStates.set(sessionName, 'paused'); |
937 | 963 | optimisticStates = new Map(optimisticStates); |
938 | 964 | if (sessionTask) { |
939 | 965 | try { await fetch(`/api/sessions/${encodeURIComponent(sessionName)}/pause`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: sessionTask.id, taskTitle: sessionTask.title, reason: 'Paused via mobile tray', killSession: true, agentName, project }) }); } catch (e) { console.warn('[TasksActive] pause failed:', e); } |
940 | 966 | } else { await handleKillSession(sessionName); } |
941 | 967 | } else if (actionId === 'convert-to-tasks') { |
| 968 | + setActionFeedback(sessionName, actionId, 'info'); |
942 | 969 | await sendWorkflowCommand(sessionName, '/jat:tasktree'); |
943 | 970 | } |
944 | 971 | } |
|
1328 | 1355 | </div> |
1329 | 1356 | <div class="mobile-action-tray" role="group" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> |
1330 | 1357 | {#each cardActions.slice(0, 4) as action} |
1331 | | - <button class="mobile-tray-btn mobile-tray-btn-{action.variant}" title={action.description} onclick={() => handleMobileAction(action.id, session.name, null, sessionAgentName, session.project || null)}> |
1332 | | - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
1333 | | - <span>{action.label}</span> |
| 1358 | + {@const fb = actionFeedback.get(`${session.name}:${action.id}`)} |
| 1359 | + <button class="mobile-tray-btn mobile-tray-btn-{fb ? fb : action.variant}" class:mobile-tray-btn-feedback={!!fb} title={action.description} disabled={!!fb} onclick={() => handleMobileAction(action.id, session.name, null, sessionAgentName, session.project || null)}> |
| 1360 | + {#if fb} |
| 1361 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 1362 | + {:else} |
| 1363 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
| 1364 | + {/if} |
| 1365 | + <span>{fb ? 'Done' : action.label}</span> |
1334 | 1366 | </button> |
1335 | 1367 | {/each} |
1336 | 1368 | </div> |
|
1345 | 1377 | {@const harness = getTaskHarness(sessionTask)} |
1346 | 1378 | {@const cardActions = getSessionStateActions(effectiveState)} |
1347 | 1379 | <div class="mobile-card-inner"> |
1348 | | - <div class="mobile-state-strip" style="background: {stateVisual.bgTint}; border-right: 2px solid {stateVisual.accent};" aria-hidden="true"> |
1349 | | - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" width="13" height="13" style="stroke: {stateVisual.accent};"><path stroke-linecap="round" stroke-linejoin="round" d={stateVisual.icon} /></svg> |
| 1380 | + <div class="mobile-state-strip mobile-state-strip-agent" style="background: {stateVisual.bgTint}; border-right: 2px solid {stateVisual.accent};"> |
| 1381 | + <AgentAvatar name={sessionAgentName} size={22} showRing={true} sessionState={effectiveState} /> |
1350 | 1382 | </div> |
1351 | 1383 | <div class="mobile-action-tray" role="group" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> |
1352 | 1384 | {#each cardActions.slice(0, 4) as action} |
1353 | | - <button class="mobile-tray-btn mobile-tray-btn-{action.variant}" title={action.description} onclick={() => handleMobileAction(action.id, session.name, sessionTask, sessionAgentName, session.project || null)}> |
1354 | | - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
1355 | | - <span>{action.label}</span> |
| 1385 | + {@const fb = actionFeedback.get(`${session.name}:${action.id}`)} |
| 1386 | + <button class="mobile-tray-btn mobile-tray-btn-{fb ? fb : action.variant}" class:mobile-tray-btn-feedback={!!fb} title={action.description} disabled={!!fb} onclick={() => handleMobileAction(action.id, session.name, sessionTask, sessionAgentName, session.project || null)}> |
| 1387 | + {#if fb} |
| 1388 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 1389 | + {:else} |
| 1390 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
| 1391 | + {/if} |
| 1392 | + <span>{fb ? 'Done' : action.label}</span> |
1356 | 1393 | </button> |
1357 | 1394 | {/each} |
1358 | 1395 | </div> |
|
1369 | 1406 | <span class="mobile-separator">·</span> |
1370 | 1407 | <span class="mobile-elapsed">{#if elapsed.showHours}{elapsed.hours}:{/if}{elapsed.minutes}:{elapsed.seconds}</span> |
1371 | 1408 | {/if} |
1372 | | - <span class="mobile-separator">·</span> |
1373 | | - <AgentAvatar name={sessionAgentName} size={16} showRing={true} sessionState={effectiveState} /> |
1374 | 1409 | <span class="mobile-agent-name">{sessionAgentName}</span> |
1375 | 1410 | {#if sessionTask.issue_type} |
1376 | 1411 | <span class="mobile-separator">·</span> |
|
1399 | 1434 | <!-- Planning / no-task session --> |
1400 | 1435 | {@const cardActions = getSessionStateActions(effectiveState)} |
1401 | 1436 | <div class="mobile-card-inner"> |
1402 | | - <div class="mobile-state-strip" style="background: {stateVisual.bgTint}; border-right: 2px solid {stateVisual.accent};" aria-hidden="true"> |
1403 | | - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" width="13" height="13" style="stroke: {stateVisual.accent};"><path stroke-linecap="round" stroke-linejoin="round" d={stateVisual.icon} /></svg> |
| 1437 | + <div class="mobile-state-strip mobile-state-strip-agent" style="background: {stateVisual.bgTint}; border-right: 2px solid {stateVisual.accent};"> |
| 1438 | + <AgentAvatar name={sessionAgentName} size={22} showRing={true} sessionState={effectiveState} /> |
1404 | 1439 | </div> |
1405 | 1440 | <div class="mobile-action-tray" role="group" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> |
1406 | 1441 | {#each cardActions.slice(0, 4) as action} |
1407 | | - <button class="mobile-tray-btn mobile-tray-btn-{action.variant}" title={action.description} onclick={() => handleMobileAction(action.id, session.name, null, sessionAgentName, session.project || null)}> |
1408 | | - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
1409 | | - <span>{action.label}</span> |
| 1442 | + {@const fb = actionFeedback.get(`${session.name}:${action.id}`)} |
| 1443 | + <button class="mobile-tray-btn mobile-tray-btn-{fb ? fb : action.variant}" class:mobile-tray-btn-feedback={!!fb} title={action.description} disabled={!!fb} onclick={() => handleMobileAction(action.id, session.name, null, sessionAgentName, session.project || null)}> |
| 1444 | + {#if fb} |
| 1445 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 1446 | + {:else} |
| 1447 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="14" height="14"><path stroke-linecap="round" stroke-linejoin="round" d={action.icon} /></svg> |
| 1448 | + {/if} |
| 1449 | + <span>{fb ? 'Done' : action.label}</span> |
1410 | 1450 | </button> |
1411 | 1451 | {/each} |
1412 | 1452 | </div> |
|
3266 | 3306 | transition: filter 0.2s; |
3267 | 3307 | } |
3268 | 3308 |
|
| 3309 | + /* Agent variant: wider to fit avatar comfortably */ |
| 3310 | + .mobile-state-strip-agent { |
| 3311 | + width: 40px; |
| 3312 | + } |
| 3313 | +
|
3269 | 3314 | /* Brighten strip on card hover */ |
3270 | 3315 | .mobile-session-card:hover .mobile-state-strip { |
3271 | | - filter: brightness(1.8) saturate(1.3); |
| 3316 | + filter: brightness(1.15) saturate(1.2); |
3272 | 3317 | } |
3273 | 3318 |
|
3274 | 3319 | /* Subtle row highlight when tray is active */ |
|
3324 | 3369 | .mobile-tray-btn-error { background: oklch(0.45 0.16 25); } |
3325 | 3370 | .mobile-tray-btn-info { background: oklch(0.48 0.14 220); } |
3326 | 3371 | .mobile-tray-btn-default { background: oklch(0.30 0.02 250); } |
| 3372 | + .mobile-tray-btn-working { background: oklch(0.52 0.14 70); } |
| 3373 | +
|
| 3374 | + /* Feedback flash animation — plays when a tray button is clicked */ |
| 3375 | + .mobile-tray-btn-feedback { |
| 3376 | + animation: tray-btn-confirm 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; |
| 3377 | + pointer-events: none; |
| 3378 | + } |
| 3379 | +
|
| 3380 | + .mobile-tray-btn-feedback.mobile-tray-btn-success { |
| 3381 | + background: oklch(0.58 0.20 145); |
| 3382 | + } |
| 3383 | + .mobile-tray-btn-feedback.mobile-tray-btn-error { |
| 3384 | + background: oklch(0.55 0.20 25); |
| 3385 | + } |
| 3386 | + .mobile-tray-btn-feedback.mobile-tray-btn-warning { |
| 3387 | + background: oklch(0.60 0.16 70); |
| 3388 | + } |
| 3389 | + .mobile-tray-btn-feedback.mobile-tray-btn-info { |
| 3390 | + background: oklch(0.56 0.16 220); |
| 3391 | + } |
| 3392 | +
|
| 3393 | + @keyframes tray-btn-confirm { |
| 3394 | + 0% { |
| 3395 | + transform: scale(1); |
| 3396 | + filter: brightness(1); |
| 3397 | + } |
| 3398 | + 30% { |
| 3399 | + transform: scale(1.12); |
| 3400 | + filter: brightness(1.4); |
| 3401 | + } |
| 3402 | + 60% { |
| 3403 | + transform: scale(0.97); |
| 3404 | + filter: brightness(1.2); |
| 3405 | + } |
| 3406 | + 100% { |
| 3407 | + transform: scale(1); |
| 3408 | + filter: brightness(1.15); |
| 3409 | + } |
| 3410 | + } |
3327 | 3411 |
|
3328 | 3412 | /* Content area (full width minus strip) */ |
3329 | 3413 | .mobile-card-body { |
|
0 commit comments