Skip to content

Commit dbae994

Browse files
fix(cockpit): stop the status flicker while you type (input-aware)
The session status flickered working(green)<->turn(amber) on every keystroke: the PTY echoes typed characters, which computeActivity read as recent agent output ("working"); a typing pause fell to "turn", which was an amber dot bucketed under "Needs you" and counted in the rail badge — so the indicator and badge flickered as you typed. Now the status is input-aware: a recent keystroke (<= INPUT_ACTIVE_MS, kept > WORKING_MS) yields a stable "your turn", never "working", so echoes can't look like agent work. "turn" also gets its own calm "Your turn" group (blue dot, separate from "Needs you"), and the rail badge counts only genuine agent questions (attention), so typing no longer inflates it. v1.6.1. 260 tests. QA 0 console/page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2d55853 commit dbae994

12 files changed

Lines changed: 41 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See every repo's state at a glance — git status, how long it's been neglected,
1111
![License](https://img.shields.io/badge/license-MIT-blue)
1212
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6)
1313
![Built with Electron](https://img.shields.io/badge/Electron-31-47848F)
14-
![Tests](https://img.shields.io/badge/tests-259%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-260%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
<img src="docs/demo/demo.gif" width="820" alt="DevDeck demo" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devdeck",
3-
"version": "1.6.0",
3+
"version": "1.6.1",
44
"description": "Project command deck — at-a-glance state + claude -c resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

src/renderer/cockpitView.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { sanitizePersistedList, type PersistedSession } from '../shared/cockpitP
77
import type { StaleLevel } from '../shared/types';
88
import { tr } from './i18n-runtime';
99

10-
interface Live { session: CockpitSession; term: Terminal; fit: FitAddon; el: HTMLElement; lastDataAt: number; recentOutput: string; openedSessionId: string | null; }
10+
interface Live { session: CockpitSession; term: Terminal; fit: FitAddon; el: HTMLElement; lastDataAt: number; lastInputAt: number; recentOutput: string; openedSessionId: string | null; }
1111
export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; sessionId?: string | null; }
1212

1313
const live = new Map<string, Live>();
@@ -96,10 +96,10 @@ async function createSession(p: OpenReq): Promise<void> {
9696
const session: CockpitSession = { id: res.id, projectPath: p.path, name: p.name, agentId: res.agentId, status: 'running', staleLevel: p.staleLevel, branch: p.branch, dirty: p.dirty, activity: 'working' };
9797
term.onData((d) => {
9898
window.devdeck.cockpit.input(res.id, d);
99-
const l = live.get(res.id); // typing answers any pending prompt → clear the buffer so 'attention' doesn't stick
100-
if (l) l.recentOutput = '';
99+
const l = live.get(res.id); // typing answers any pending prompt → clear the buffer + mark input so it reads as "your turn", not "working"
100+
if (l) { l.recentOutput = ''; l.lastInputAt = Date.now(); }
101101
});
102-
live.set(res.id, { session, term, fit, el, lastDataAt: Date.now(), recentOutput: '', openedSessionId: p.sessionId ?? null });
102+
live.set(res.id, { session, term, fit, el, lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', openedSessionId: p.sessionId ?? null });
103103
restorable = restorable.filter((r) => r.projectPath !== p.path); // now live → no longer a "previous" entry
104104
select(res.id);
105105
updateRailBadge();
@@ -123,7 +123,7 @@ function tickActivity(): void {
123123
const now = Date.now();
124124
let changed = false;
125125
for (const l of live.values()) {
126-
const next = computeActivity({ exited: l.session.status === 'exited', lastDataAt: l.lastDataAt, now, recentOutput: l.recentOutput });
126+
const next = computeActivity({ exited: l.session.status === 'exited', lastDataAt: l.lastDataAt, lastInputAt: l.lastInputAt, now, recentOutput: l.recentOutput });
127127
if (next !== l.session.activity) { l.session.activity = next; changed = true; }
128128
}
129129
if (changed) { renderList(); updateRailBadge(); }

src/renderer/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"cockpit.st_exited": "Exited",
115115
"cockpit.grp_attention": "Needs you",
116116
"cockpit.grp_working": "Working",
117+
"cockpit.grp_turn": "Your turn",
117118
"cockpit.grp_idle": "Idle / exited",
118119
"cockpit.prev_sessions": "Previous",
119120
"cockpit.restore": "Restore",

src/renderer/locales/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"cockpit.st_exited": "終了",
115115
"cockpit.grp_attention": "要対応",
116116
"cockpit.grp_working": "実行中",
117+
"cockpit.grp_turn": "あなたの番",
117118
"cockpit.grp_idle": "アイドル / 終了",
118119
"cockpit.prev_sessions": "前回のセッション",
119120
"cockpit.restore": "復元",

src/renderer/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"cockpit.st_exited": "종료",
115115
"cockpit.grp_attention": "주목 필요",
116116
"cockpit.grp_working": "작업 중",
117+
"cockpit.grp_turn": "내 차례",
117118
"cockpit.grp_idle": "유휴 / 종료",
118119
"cockpit.prev_sessions": "이전 세션",
119120
"cockpit.restore": "복원",

src/renderer/locales/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"cockpit.st_exited": "已退出",
115115
"cockpit.grp_attention": "需要关注",
116116
"cockpit.grp_working": "工作中",
117+
"cockpit.grp_turn": "轮到你",
117118
"cockpit.grp_idle": "空闲 / 已退出",
118119
"cockpit.prev_sessions": "上次会话",
119120
"cockpit.restore": "恢复",

src/renderer/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ button.primary:hover { background: var(--accent-hover); }
287287
.ck-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); }
288288
.ck-spin { width: 10px; height: 10px; border: 1.6px solid #3fb950; border-right-color: transparent; border-radius: 50%; animation: ck-spin .7s linear infinite; }
289289
@keyframes ck-spin { to { transform: rotate(360deg); } }
290-
.ck-row.act-turn .ck-dot { background: var(--warn); }
290+
.ck-row.act-turn .ck-dot { background: var(--accent); }
291291
.ck-row.act-idle .ck-dot { background: var(--neutral); opacity: .55; }
292292
.ck-row.act-exited .ck-dot { background: transparent; border: 1.5px solid var(--text-faint); }
293293
.ck-row.act-attention { background: #241512; border-left-color: var(--bad); }

src/shared/cockpitModel.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,21 @@ describe('groupByActivity', () => {
3030
s({ id: 'i', name: 'i', activity: 'idle' }),
3131
];
3232
const g = groupByActivity(list);
33-
expect(g.map((x) => x.bucket)).toEqual(['attention', 'working', 'idle']);
34-
expect(g[0].items.map((x) => x.id)).toEqual(['a', 't']); // attention before turn
33+
expect(g.map((x) => x.bucket)).toEqual(['attention', 'working', 'turn', 'idle']);
34+
expect(g[0].items.map((x) => x.id)).toEqual(['a']);
3535
expect(g[1].items.map((x) => x.id)).toEqual(['w']);
36-
expect(g[2].items.map((x) => x.id)).toEqual(['i', 'e']); // idle before exited
36+
expect(g[2].items.map((x) => x.id)).toEqual(['t']); // 'turn' is its own "Your turn" group
37+
expect(g[3].items.map((x) => x.id)).toEqual(['i', 'e']); // idle before exited
3738
});
3839
it('omits empty buckets', () => {
3940
expect(groupByActivity([s({ activity: 'working' })]).map((x) => x.bucket)).toEqual(['working']);
4041
});
4142
});
4243

4344
describe('needsAttentionCount', () => {
44-
it('counts attention + turn', () => {
45+
it('counts only attention (a question) — not turn / typing', () => {
4546
const list = [s({ activity: 'attention' }), s({ activity: 'turn' }), s({ activity: 'working' }), s({ activity: 'idle' })];
46-
expect(needsAttentionCount(list)).toBe(2);
47+
expect(needsAttentionCount(list)).toBe(1);
4748
});
4849
});
4950

src/shared/cockpitModel.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,23 @@ export function filterSessions(list: CockpitSession[], query: string): CockpitSe
2222
s.name.toLowerCase().includes(q) || (s.branch ?? '').toLowerCase().includes(q));
2323
}
2424

25-
const ACTIVITY_RANK: Record<ActivityState, number> = { attention: 0, turn: 1, working: 2, idle: 3, exited: 4 };
26-
const BUCKET_OF: Record<ActivityState, 'attention' | 'working' | 'idle'> = { attention: 'attention', turn: 'attention', working: 'working', idle: 'idle', exited: 'idle' };
27-
const BUCKET_ORDER = ['attention', 'working', 'idle'] as const;
25+
type Bucket = 'attention' | 'working' | 'turn' | 'idle';
26+
const ACTIVITY_RANK: Record<ActivityState, number> = { attention: 0, working: 1, turn: 2, idle: 3, exited: 4 };
27+
// 'turn' is its own calm "Your turn" bucket (separate from "Needs you"), so an active/just-finished
28+
// session isn't lumped with genuine agent questions and doesn't inflate the needs-you badge.
29+
const BUCKET_OF: Record<ActivityState, Bucket> = { attention: 'attention', working: 'working', turn: 'turn', idle: 'idle', exited: 'idle' };
30+
const BUCKET_ORDER = ['attention', 'working', 'turn', 'idle'] as const;
2831

29-
export function groupByActivity(list: CockpitSession[]): { bucket: 'attention' | 'working' | 'idle'; items: CockpitSession[] }[] {
32+
export function groupByActivity(list: CockpitSession[]): { bucket: Bucket; items: CockpitSession[] }[] {
3033
const sorted = [...list].sort((a, b) => ACTIVITY_RANK[a.activity] - ACTIVITY_RANK[b.activity] || a.name.localeCompare(b.name));
3134
return BUCKET_ORDER
3235
.map((bucket) => ({ bucket, items: sorted.filter((s) => BUCKET_OF[s.activity] === bucket) }))
3336
.filter((g) => g.items.length > 0);
3437
}
3538

39+
/** The rail badge surfaces only sessions where the AGENT is waiting on you (a question) — not your turn / typing. */
3640
export function needsAttentionCount(list: CockpitSession[]): number {
37-
return list.filter((s) => s.activity === 'attention' || s.activity === 'turn').length;
41+
return list.filter((s) => s.activity === 'attention').length;
3842
}
3943

4044
/** The cockpit (embedded node-pty terminals) is Windows-only for now; other OSes keep the external terminal. */

0 commit comments

Comments
 (0)