Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b1fefa5
fix(notifications): backstop wedged rings with a watchdog and pause/a…
danshapiro Oct 22, 2026
76301eb
refactor(notifications): name the ring lifecycle createRingAttempt
danshapiro Oct 22, 2026
11586dc
fix(fresh-agent): refresh hidden approvals and questions on reveal wi…
danshapiro Oct 22, 2026
a8818c7
refactor(fresh-agent): extract fresh-agent event classification
danshapiro Oct 22, 2026
b8fab11
fix(fresh-agent): start new and resumed claude panes on the saved def…
danshapiro Oct 22, 2026
a1c3023
fix(fresh-agent): carry a saved default model's effort levels into ne…
danshapiro Oct 22, 2026
7ddab9a
test(e2e): new and resumed claude panes start on the saved default model
danshapiro Oct 22, 2026
e62dcc1
feat(tabs): shared tab-attention selectors, class helper and indicator
danshapiro Oct 22, 2026
ed16476
feat(mobile): show needs-attention marks on the mobile tab strip
danshapiro Oct 22, 2026
d32fa5c
feat(mobile): mark and dismiss needs-attention tabs in the tab switcher
danshapiro Oct 22, 2026
61753f4
refactor(tabs): one visit thunk and one attention class owner for eve…
danshapiro Oct 22, 2026
027a175
test(e2e): mobile tab strip and switcher show needs-attention marks; …
danshapiro Oct 22, 2026
ad8ac8d
test(e2e): resume the default-model journey from a listed, flavour-ta…
danshapiro Oct 22, 2026
1412395
fix(tabs): keep the other-tabs attention status mounted so changes ar…
danshapiro Oct 22, 2026
bfe3bf2
test(mobile): pin the visible attention dots on the strip's chevrons
danshapiro Oct 22, 2026
e3525dd
refactor(tabs): every tab surface reads one needs-attention derivation
danshapiro Oct 22, 2026
e43aaa5
docs(agents): list every way a notification ring releases the queue
danshapiro Oct 22, 2026
34885c2
test(e2e): state what the G11 and G17 specs do not cover
danshapiro Oct 22, 2026
88baf8c
test(e2e): approvals raised or cancelled in a hidden pane are current…
danshapiro Oct 22, 2026
60e7346
Merge origin/main into feat/freshclaude-client-alerts
danshapiro Oct 22, 2026
f6bbd67
test(e2e): drive real turns for the mobile needs-attention marks
danshapiro Oct 22, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions crates/freshell-server/src/settings_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2677,6 +2677,59 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}

/// G11: the effort levels saved with a fresh-pane default model travel
/// with it through PATCH, GET, config.json and restart, and the client's
/// `null` clear sentinel removes them (the `freshAgent.providers` subtree
/// is opaque to the server and deep-merged).
#[tokio::test]
async fn fresh_agent_provider_model_effort_levels_round_trip_and_clear() {
let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like()));
std::fs::create_dir_all(dir.join(".freshell")).unwrap();
let store = store_at(&dir);
let levels = json!({ "modelId": "claude-haiku-x", "levels": [] });

store
.patch(&json!({ "freshAgent": { "providers": { "freshclaude": {
"modelSelection": { "kind": "exact", "modelId": "claude-haiku-x" },
"modelEffortLevels": levels,
} } } }))
.await
.unwrap();
let got = store.get().await;
assert_eq!(
got.fresh_agent.providers["freshclaude"]["modelEffortLevels"],
levels
);

// An unrelated provider patch keeps the levels.
store
.patch(
&json!({ "freshAgent": { "providers": { "freshclaude": { "effort": "low" } } } }),
)
.await
.unwrap();
let restarted = store_at(&dir);
let after = restarted.get().await;
assert_eq!(
after.fresh_agent.providers["freshclaude"]["modelEffortLevels"],
levels
);
assert_eq!(after.fresh_agent.providers["freshclaude"]["effort"], "low");

// The clear sentinel removes them.
let cleared = restarted
.patch(&json!({ "freshAgent": { "providers": { "freshclaude": { "modelEffortLevels": null } } } }))
.await
.unwrap();
assert!(cleared.fresh_agent.providers["freshclaude"]["modelEffortLevels"].is_null());
assert_eq!(
cleared.fresh_agent.providers["freshclaude"]["modelSelection"]["modelId"],
"claude-haiku-x"
);

std::fs::remove_dir_all(&dir).ok();
}

fn valid5() -> Vec<String> {
["claude", "codex", "gemini", "kimi", "opencode"]
.iter()
Expand Down
4 changes: 4 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
/* On mobile (<768px): tab bar is replaced by MobileTabStrip (centered tab name
with chevrons + tab switcher grid overlay). At the right edge, the chevron
becomes a + action on the last tab to create a new tab. Touch targets are 44px min.
Needs-attention marks match the desktop tabs (and the "Tab attention style"
setting): the active title takes the green pill, a green dot plus count reads
"N other tabs need attention", the chevron toward a marked tab gets a dot, and
switcher cards show a "Needs attention" badge; tapping a card dismisses it.
Sidebar opens/closes via swipe gesture and uses full-width drawer mode.
Pull-to-refresh is disabled, safe-area insets are respected, and fullscreen/landscape
mode minimizes chrome for terminal-focused use. */
Expand Down
26 changes: 25 additions & 1 deletion shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,23 @@ export type CodingCliSettings = {
mcpServer: boolean
}

/**
* The effort levels a fresh-pane default model offers, captured when the
* model was picked (catalog-only claude models are absent from the static
* model table). Paired with the model id so a later model change can never
* pick up the wrong levels; an empty list means the model declares none.
*/
export type FreshAgentModelEffortLevels = {
modelId: string
levels: string[]
}

export type FreshAgentProviderDefaults = {
modelSelection?: FreshAgentModelSelection
defaultPermissionMode?: string
effort?: FreshAgentEffort
style?: FreshAgentStyle
modelEffortLevels?: FreshAgentModelEffortLevels
}

export type ServerSettings = {
Expand Down Expand Up @@ -746,13 +758,21 @@ function createCodingCliProviderConfigPatchSchema() {
.strict()
}

const FreshAgentModelEffortLevelsSchema = z
.object({
modelId: z.string().min(1),
levels: z.array(z.string()),
})
.strict()

function createFreshAgentProviderDefaultsSchema() {
return z
.object({
modelSelection: FreshAgentModelSelectionSchema.optional(),
defaultPermissionMode: z.string().optional(),
effort: FreshAgentModelCapabilitiesOpaqueStringSchema.optional(),
style: FreshAgentStyleSchema.optional(),
modelEffortLevels: FreshAgentModelEffortLevelsSchema.optional(),
})
.strict()
}
Expand All @@ -764,6 +784,7 @@ function createFreshAgentProviderDefaultsPatchSchema() {
defaultPermissionMode: z.string().optional(),
effort: z.union([FreshAgentModelCapabilitiesOpaqueStringSchema, z.literal('')]).nullable().optional(),
style: FreshAgentStyleSchema.optional(),
modelEffortLevels: FreshAgentModelEffortLevelsSchema.nullable().optional(),
})
.strict()
}
Expand Down Expand Up @@ -1030,6 +1051,9 @@ function sanitizeFreshAgentServerSettingsPatchInput(
if (hasOwn(normalizedProviderPatchInput, 'style')) {
normalizedProviderPatch.style = parsed.data.style
}
if (hasOwn(normalizedProviderPatchInput, 'modelEffortLevels')) {
normalizedProviderPatch.modelEffortLevels = parsed.data.modelEffortLevels ?? undefined
}
providers[providerName] = normalizedProviderPatch
}
}
Expand Down Expand Up @@ -1268,7 +1292,7 @@ function normalizeLegacyFreshAgentProviderDefaultsInput(

const normalized = pickOwnKeysPreservingUndefined(
providerPatch,
['modelSelection', 'defaultPermissionMode', 'effort', 'style'],
['modelSelection', 'defaultPermissionMode', 'effort', 'style', 'modelEffortLevels'],
)

if (
Expand Down
65 changes: 62 additions & 3 deletions src/components/MobileTabStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useId } from 'react'
import { ChevronLeft, ChevronRight, PanelLeft, Plus } from 'lucide-react'
import { useAppDispatch, useAppSelector } from '@/store/hooks'
import { selectAttentionTabIds, selectTabAttentionStyle } from '@/store/selectors/tabAttentionSelectors'
import { otherTabsNeedAttentionLabel, tabAttentionClassName } from '@/lib/tab-attention'
import { cn } from '@/lib/utils'
import { TabAttentionIndicator } from '@/components/TabAttentionIndicator'
import { addTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice'
import { getTabDisplayTitle } from '@/lib/tab-title'
import { getBusyPaneIdsForTab } from '@/lib/pane-activity'
Expand Down Expand Up @@ -35,10 +40,25 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb
(s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID
)
const extensions = useAppSelector((s) => s.extensions?.entries)
const attentionTabIds = useAppSelector(selectAttentionTabIds)
const tabAttentionStyle = useAppSelector(selectTabAttentionStyle)
const descriptionIdBase = useId()

const activeIndex = tabs.findIndex((t) => t.id === activeTabId)
const activeTab = activeIndex >= 0 ? tabs[activeIndex] : null

// Needs-attention marks (same style-gated union as the desktop tabs): the
// active title, a count of the OTHER marked tabs, and a dot on the chevron
// pointing toward marked tabs.
const activeNeedsAttention = Boolean(activeTab && attentionTabIds.includes(activeTab.id))
const otherAttentionCount = attentionTabIds.length - (activeNeedsAttention ? 1 : 0)
const attentionIndexes = attentionTabIds.map((id) => tabs.findIndex((t) => t.id === id))
const earlierNeedsAttention = activeIndex >= 0 && attentionIndexes.some((index) => index >= 0 && index < activeIndex)
const laterNeedsAttention = activeIndex >= 0 && attentionIndexes.some((index) => index > activeIndex)
const activeDescriptionId = `${descriptionIdBase}-active-attention`
const prevDescriptionId = `${descriptionIdBase}-prev-attention`
const nextDescriptionId = `${descriptionIdBase}-next-attention`

const displayTitle = activeTab
? getTabDisplayTitle(activeTab, paneLayouts[activeTab.id], paneTitles?.[activeTab.id], extensions)
: ''
Expand Down Expand Up @@ -69,6 +89,9 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb

return (
<div className="relative z-20 h-12 shrink-0 flex items-center px-2 bg-background border-b border-border/30">
{activeNeedsAttention && <span id={activeDescriptionId} className="sr-only">This tab needs attention</span>}
{earlierNeedsAttention && <span id={prevDescriptionId} className="sr-only">An earlier tab needs attention</span>}
{laterNeedsAttention && <span id={nextDescriptionId} className="sr-only">A later tab needs attention</span>}
{sidebarCollapsed && onToggleSidebar && (
<button
className="min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
Expand All @@ -80,21 +103,41 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb
</button>
)}
<button
className="min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground disabled:opacity-30"
className="relative min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground disabled:opacity-30"
onClick={() => { triggerHapticFeedback(); dispatch(switchToPrevTab()) }}
disabled={isFirst}
aria-label="Previous tab"
aria-describedby={earlierNeedsAttention ? prevDescriptionId : undefined}
>
<ChevronLeft className="h-5 w-5" />
{earlierNeedsAttention && (
<TabAttentionIndicator
variant="dot"
label="An earlier tab needs attention"
attentionStyle={tabAttentionStyle}
className="absolute right-1.5 top-2"
/>
)}
</button>

<button
className="flex-1 flex items-center justify-center gap-2 min-h-11 rounded-md"
onClick={() => { triggerHapticFeedback(); onOpenSwitcher?.() }}
aria-label="Open tab switcher"
aria-describedby={activeNeedsAttention ? activeDescriptionId : undefined}
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-sm font-medium truncate max-w-[160px]">
<span
className={cn(
'text-sm font-medium truncate max-w-[160px]',
tabAttentionClassName({
needsAttention: activeNeedsAttention,
style: tabAttentionStyle,
active: true,
surface: 'pill',
}),
)}
>
{displayTitle || 'Untitled'}
</span>
{isActiveBusy && (
Expand All @@ -111,12 +154,28 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb
</span>
</button>

<TabAttentionIndicator
count={otherAttentionCount}
label={otherTabsNeedAttentionLabel(otherAttentionCount)}
attentionStyle={tabAttentionStyle}
className="px-1"
/>

<button
className="min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
className="relative min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
onClick={handleRightAction}
aria-label={isLast ? 'New tab' : 'Next tab'}
aria-describedby={laterNeedsAttention ? nextDescriptionId : undefined}
>
{isLast ? <Plus className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
{laterNeedsAttention && (
<TabAttentionIndicator
variant="dot"
label="A later tab needs attention"
attentionStyle={tabAttentionStyle}
className="absolute right-1.5 top-2"
/>
)}
</button>
</div>
)
Expand Down
59 changes: 59 additions & 0 deletions src/components/TabAttentionIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { cn } from '@/lib/utils'
import { tabAttentionClassName } from '@/lib/tab-attention'
import type { TabAttentionStyle } from '@/store/types'

interface TabAttentionIndicatorProps {
/** How many tabs carry the mark; the status is empty at 0. Omit for a bare mark. */
count?: number
/** What the mark means, e.g. "2 other tabs need attention". */
label: string
/** `dot`: a decorative mark inside a control whose own name/title carries
* the meaning (aria-hidden). `count` (default): a polite live status. */
variant?: 'count' | 'dot'
/** The user's tabAttentionStyle (named apart from React's `style` prop). */
attentionStyle?: TabAttentionStyle
className?: string
}

/**
* The shared needs-attention mark for tab surfaces (mobile tab strip, its
* chevrons). Visual tokens come from `tabAttentionClassName`, so it follows
* the user's tabAttentionStyle like the desktop tabs.
*/
export function TabAttentionIndicator({
count,
label,
variant = 'count',
attentionStyle = 'highlight',
className,
}: TabAttentionIndicatorProps) {
const dotClassName = cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
tabAttentionClassName({ needsAttention: true, style: attentionStyle, surface: 'indicator' }),
)

if (variant === 'dot') {
return <span aria-hidden="true" className={cn(dotClassName, className)} />
}

// The live region stays mounted — empty while nothing is marked — so a new
// or changed count is a text change inside an existing polite region, the
// shape screen readers reliably announce (a region inserted already filled,
// or a changed aria-label, is generally not announced). The label is the
// announced text; the dot and the number are visual only.
const showMark = count === undefined || count > 0
return (
<span role="status" aria-live="polite" aria-atomic="true">
{showMark ? (
<span
title={label}
className={cn('inline-flex shrink-0 items-center gap-1 text-[13px] font-semibold', className)}
>
<span aria-hidden="true" className={dotClassName} />
{count !== undefined ? <span aria-hidden="true">{count}</span> : null}
<span className="sr-only">{label}</span>
</span>
) : null}
</span>
)
}
Loading
Loading