Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 103 additions & 12 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
toggleSidebar,
reorderTask,
getTaskDotStatus,
getTaskAttentionState,
taskNeedsAttention,
getTaskViewportVisibility,
registerFocusFn,
unregisterFocusFn,
focusSidebar,
Expand All @@ -22,6 +25,7 @@ import {
isProjectMissing,
} from '../store/store';
import type { Project } from '../store/types';
import type { TaskAttentionState } from '../store/store';
import { computeGroupedTasks } from '../store/sidebar-order';
import { ConnectPhoneModal } from './ConnectPhoneModal';
import { ConfirmDialog } from './ConfirmDialog';
Expand All @@ -39,6 +43,42 @@ const SIDEBAR_MIN_WIDTH = 160;
const SIDEBAR_MAX_WIDTH = 480;
const SIDEBAR_SIZE_KEY = 'sidebar:width';

function getAttentionColor(attention: TaskAttentionState): string | null {
if (attention === 'active') return theme.accent;
if (attention === 'needs_input') return theme.warning;
if (attention === 'error') return theme.error;
return null;
}

interface OffscreenAttentionInfo {
attention: TaskAttentionState;
color: string;
label: string | null;
}

function getOffscreenAttentionInfo(taskId: string): OffscreenAttentionInfo | null {
const visibility = getTaskViewportVisibility(taskId);
if (!visibility || visibility === 'visible' || !taskNeedsAttention(taskId)) return null;
const attention = getTaskAttentionState(taskId);
const color = getAttentionColor(attention) ?? theme.accent;
const side = visibility === 'offscreen-left' ? 'left' : 'right';
const prefix = visibility === 'offscreen-left' ? '←' : '→';
let label: string | null = null;
if (attention === 'needs_input') label = `${prefix} input (${side})`;
if (attention === 'error') label = `${prefix} error (${side})`;
return { attention, color, label };
}

function createOffscreenAttentionState(taskId: () => string) {
const info = createMemo(() => getOffscreenAttentionInfo(taskId()));
return {
hasAttention: () => info() !== null,
attention: () => info()?.attention,
color: () => info()?.color ?? theme.accent,
label: () => info()?.label ?? null,
};
}

export function Sidebar() {
const [confirmRemove, setConfirmRemove] = createSignal<string | null>(null);
const [editingProject, setEditingProject] = createSignal<Project | null>(null);
Expand Down Expand Up @@ -725,8 +765,30 @@ function CurrentBranchBadge(props: { branchName: string }) {
);
}

function OffscreenAttentionBadge(props: { taskId: string }) {
const offscreenAttention = createOffscreenAttentionState(() => props.taskId);
return (
<Show when={offscreenAttention.label()}>
{(text) => (
<span
class="sidebar-offscreen-attention-badge"
title={text()}
style={{
color: offscreenAttention.color(),
border: `1px solid color-mix(in srgb, ${offscreenAttention.color()} 30%, transparent)`,
background: `color-mix(in srgb, ${offscreenAttention.color()} 10%, transparent)`,
}}
>
{text()}
</span>
)}
</Show>
);
}

function CollapsedTaskRow(props: { taskId: string }) {
const task = () => store.tasks[props.taskId];
const offscreenAttention = createOffscreenAttentionState(() => props.taskId);
return (
<Show when={task()}>
{(t) => (
Expand All @@ -746,29 +808,40 @@ function CollapsedTaskRow(props: { taskId: string }) {
style={{
padding: '7px 10px',
'border-radius': '6px',
background: 'transparent',
color: theme.fgSubtle,
background: offscreenAttention.hasAttention()
? `color-mix(in srgb, ${offscreenAttention.color()} 10%, transparent)`
: 'transparent',
color: offscreenAttention.hasAttention() ? theme.fg : theme.fgSubtle,
'font-size': sf(12),
'font-weight': '400',
cursor: 'pointer',
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
opacity: '0.6',
opacity: offscreenAttention.hasAttention() ? '1' : '0.6',
display: 'flex',
'align-items': 'center',
gap: '6px',
border:
store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId
? `1.5px solid var(--border-focus)`
: '1.5px solid transparent',
: offscreenAttention.hasAttention()
? `1.5px solid color-mix(in srgb, ${offscreenAttention.color()} 38%, transparent)`
: '1.5px solid transparent',
}}
>
<StatusDot status={getTaskDotStatus(props.taskId)} size="sm" />
<StatusDot
status={getTaskDotStatus(props.taskId)}
size="sm"
attention={offscreenAttention.attention()}
/>
<Show when={t().gitIsolation === 'direct'}>
<CurrentBranchBadge branchName={t().branchName} />
</Show>
<span style={{ overflow: 'hidden', 'text-overflow': 'ellipsis' }}>{t().name}</span>
<span style={{ flex: '1', overflow: 'hidden', 'text-overflow': 'ellipsis' }}>
{t().name}
</span>
<OffscreenAttentionBadge taskId={props.taskId} />
</div>
)}
</Show>
Expand All @@ -785,6 +858,7 @@ interface TaskRowProps {
function TaskRow(props: TaskRowProps) {
const task = () => store.tasks[props.taskId];
const idx = () => props.globalIndex(props.taskId);
const offscreenAttention = createOffscreenAttentionState(() => props.taskId);
return (
<Show when={task()}>
{(t) => (
Expand All @@ -802,10 +876,18 @@ function TaskRow(props: TaskRowProps) {
style={{
padding: '7px 10px',
'border-radius': '6px',
background: 'transparent',
color: store.activeTaskId === props.taskId ? theme.fg : theme.fgMuted,
background: offscreenAttention.hasAttention()
? `color-mix(in srgb, ${offscreenAttention.color()} 10%, transparent)`
: 'transparent',
color:
store.activeTaskId === props.taskId || offscreenAttention.hasAttention()
? theme.fg
: theme.fgMuted,
'font-size': sf(12),
'font-weight': store.activeTaskId === props.taskId ? '500' : '400',
'font-weight':
store.activeTaskId === props.taskId || offscreenAttention.hasAttention()
? '500'
: '400',
cursor: props.dragFromIndex() !== null ? 'grabbing' : 'pointer',
'white-space': 'nowrap',
overflow: 'hidden',
Expand All @@ -817,10 +899,16 @@ function TaskRow(props: TaskRowProps) {
border:
store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId
? `1.5px solid var(--border-focus)`
: '1.5px solid transparent',
: offscreenAttention.hasAttention()
? `1.5px solid color-mix(in srgb, ${offscreenAttention.color()} 38%, transparent)`
: '1.5px solid transparent',
}}
>
<StatusDot status={getTaskDotStatus(props.taskId)} size="sm" />
<StatusDot
status={getTaskDotStatus(props.taskId)}
size="sm"
attention={offscreenAttention.attention()}
/>
<Show when={t().gitIsolation === 'direct'}>
<span
style={{
Expand All @@ -837,7 +925,10 @@ function TaskRow(props: TaskRowProps) {
{t().branchName}
</span>
</Show>
<span style={{ overflow: 'hidden', 'text-overflow': 'ellipsis' }}>{t().name}</span>
<span style={{ flex: '1', overflow: 'hidden', 'text-overflow': 'ellipsis' }}>
{t().name}
</span>
<OffscreenAttentionBadge taskId={props.taskId} />
</div>
</>
)}
Expand Down
31 changes: 26 additions & 5 deletions src/components/StatusDot.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import type { TaskDotStatus } from '../store/taskStatus';
import type { TaskAttentionState, TaskDotStatus } from '../store/taskStatus';
import { theme } from '../lib/theme';

const SIZES = { sm: 6, md: 8 } as const;

function getDotColor(status: TaskDotStatus): string {
function getDotColor(status: TaskDotStatus, attention?: TaskAttentionState): string {
if (attention === 'active') return theme.accent;
if (attention === 'needs_input') return theme.warning;
if (attention === 'error') return theme.error;
if (attention === 'ready') return theme.success;
return { busy: theme.fgMuted, waiting: '#e5a800', ready: theme.success }[status];
}

export function StatusDot(props: { status: TaskDotStatus; size?: 'sm' | 'md' }) {
function getDotShadow(attention?: TaskAttentionState): string | undefined {
if (!attention || attention === 'idle' || attention === 'ready') return undefined;
const color =
attention === 'active'
? theme.accent
: attention === 'needs_input'
? theme.warning
: theme.error;
return `0 0 0 2px color-mix(in srgb, ${color} 22%, transparent)`;
}

export function StatusDot(props: {
status: TaskDotStatus;
size?: 'sm' | 'md';
attention?: TaskAttentionState;
}) {
const px = () => SIZES[props.size ?? 'sm'];
const isPulsing = () => props.attention === 'active' || props.status === 'busy';
return (
<span
class={props.status === 'busy' ? 'status-dot-pulse' : undefined}
class={isPulsing() ? 'status-dot-pulse' : undefined}
style={{
display: 'inline-block',
width: `${px()}px`,
height: `${px()}px`,
'border-radius': '50%',
background: getDotColor(props.status),
background: getDotColor(props.status, props.attention),
'box-shadow': getDotShadow(props.attention),
'flex-shrink': '0',
}}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import { invoke, fireAndForget, Channel } from '../lib/ipc';
import { IPC } from '../../electron/ipc/channels';
import { getTerminalFontFamily } from '../lib/fonts';
import { TERMINAL_SCROLLBACK_LINES } from '../lib/terminalConstants';
import { getTerminalTheme } from '../lib/theme';
import { matchesGlobalShortcut } from '../lib/shortcuts';
import { isMac } from '../lib/platform';
Expand Down Expand Up @@ -85,7 +86,7 @@ export function TerminalView(props: TerminalViewProps) {
fontFamily: getTerminalFontFamily(store.terminalFont),
theme: getTerminalTheme(store.themePreset),
allowProposedApi: true,
scrollback: 3000,
scrollback: TERMINAL_SCROLLBACK_LINES,
});

fitAddon = new FitAddon();
Expand Down
Loading
Loading