Skip to content

Commit 5919238

Browse files
Show machine loading progress and retain earlier terminal output
1 parent 056d1fd commit 5919238

16 files changed

Lines changed: 168 additions & 12 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 repository, live agent session, next task, local usage estimate, and r
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-43-47848F)
14-
![Tests](https://img.shields.io/badge/tests-1254%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-1255%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
</div>

‎docs/quality-review-1.37.7.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# PC 전환 진행 표시와 터미널 상단 확인
2+
3+
## 확인 및 개선
4+
5+
| 항목 | 확인한 원인 | 처리 |
6+
| --- | --- | --- |
7+
| PC 전환이 멈춘 것처럼 보임 | 프로젝트 요청은 최대 150초를 기다리지만 회색 자리 표시자만 표시 | 상단 고정 도구 모음에 대상 PC 이름과 회전 표시. 8초 후 지연 안내. 전환 조작은 계속 허용 |
8+
| 전환을 반복할 때 진행 상태 혼동 | 이전 요청의 완료가 현재 요청보다 늦을 수 있음 | 선택 세대와 요청 번호가 현재일 때만 상태를 종료. 실패·재시도에도 같은 규칙 적용 |
9+
| 터미널 이전 출력이 일찍 사라짐 | xterm 기본 보관량 1,000줄 사용 | 세션당 최대 10,000줄 보관 |
10+
| 접속·화면 복구 시 첫 출력 줄 누락 | 보관 상한에 도달하지 않은 출력도 첫 줄을 무조건 제거 | 실제 앞부분이 잘린 버퍼에만 경계 복구 적용. 온전한 첫 줄과 커서 제어 문자 보존 |
11+
12+
4개 언어와 화면 읽기 프로그램용 진행 상태를 적용했다. 로딩 표시를 위해 진행률을 추정하거나 중복 요청을 보내지 않는다. 데이터 스캔 및 네트워크 대기 시간 자체를 단축하는 변경은 아니다.
13+
14+
## QA
15+
16+
로컬 전체 단위 검사: 1,255개 통과, 기존 1개 생략. 로컬 Electron 복원력 검사: 9개 시나리오 통과.
17+
18+
- 기존 저장 실패·초안 보존·재시작·응답 역전 검사 유지.
19+
- 원격 응답을 보류하고 즉시 로딩 문구, 8초 후 지연 문구, A→B→A 전환 중 이전 실패 무시 확인.
20+
- 원격 실패 후 로딩 종료, 재시도 시 로딩 재개, 성공 시 종료 확인.
21+
- Windows 네이티브 터미널에 1,500줄 출력 후 검색으로 최초 출력에 접근.
22+
- 스크롤한 설정 화면에서 터미널로 이동하고 800×600, 1280×800 창 크기에서 제목·터미널의 상단 및 하단 경계 검사.
23+
- 터미널 버퍼 단위 검사에 온전한 첫 줄, 잘린 이스케이프 접두부, 줄바꿈 없는 화면 갱신, 크기 변경 후 첫 줄 보존 추가.
24+
25+
## 구분해야 하는 제한
26+
27+
제목이나 버튼이 화면 위로 잘리는 현상은 이번 탐색과 경계 검사에서는 재현하지 못했다. 출력 기록의 누락과 같은 문제로 단정하지 않았다.
28+
29+
10,000줄은 실행 중인 화면의 보관 상한이다. 원격 연결 시 받는 출력은 256 KiB로 제한되며 크기 변경 때 화면 왜곡을 막기 위해 이전 크기의 원시 출력을 폐기한다. 재연결·수동 화면 복구는 최근 출력으로 화면을 다시 만들기 때문에 모든 과거 기록을 보장하지 않는다. 이를 보장하려면 화면 복구용 데이터와 영구 대화 기록을 분리하는 후속 설계가 필요하다. 기존에 폐기된 출력은 이번 변경으로 복구되지 않는다.

‎docs/releases/1.37.7.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# DevDeck 1.37.7
2+
3+
- Projects now shows a visible, localized loading indicator naming the selected PC. After eight seconds it explains that loading is taking longer and that switching PCs remains available.
4+
- Loading state follows the current request: obsolete responses cannot dismiss it, failures stop it, and Retry displays progress again. The notice stays in the sticky toolbar while scrolling.
5+
- Terminal scrollback increases from the xterm default of 1,000 lines to 10,000 lines, keeping earlier output accessible during longer sessions.
6+
- Attaching to a running terminal preserves the first output line and its initial cursor-control sequences. Only buffers actually truncated by the memory cap discard an incomplete prefix.
7+
- Electron QA now checks delayed loading, stale responses, failed-load recovery, searching the start of 1,500 lines of native terminal output, and terminal boundaries after navigation and window resizing.
8+
9+
Terminal scrollback remains bounded. Remote attachment and screen recovery use recent output rather than a permanent transcript; this release does not guarantee all output survives a reconnect, refresh, or application restart. The reported title/top-edge clipping was not reproduced in the navigation and resize checks.

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎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.37.6",
3+
"version": "1.37.7",
44
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

‎qa/resilience.mjs‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const bin = join(sandbox, 'bin');
1717
const out = join(root, 'qa', 'shots', 'resilience');
1818
for (const p of [repo, userData, out, bin]) mkdirSync(p, { recursive: true });
1919
// Exercise ConPTY without depending on a logged-in provider or starting another agent.
20-
for (const name of ['claude', 'codex', 'antigravity']) writeFileSync(join(bin, name + '.cmd'), '@echo off\r\necho DEVDECK_QA_PTY_READY\r\n');
20+
for (const name of ['claude', 'codex', 'antigravity']) writeFileSync(join(bin, name + '.cmd'), '@echo off\r\necho DEVDECK_QA_HISTORY_START\r\nfor /L %%i in (1,1,1500) do @echo History line %%i\r\necho DEVDECK_QA_PTY_READY\r\n');
2121
execFileSync('git', ['init', '-q', '-b', 'qa-fixture'], { cwd: repo });
2222
const stateFile = join(userData, 'state.json');
2323
writeFileSync(stateFile, JSON.stringify({ projects: {}, settings: { language: 'en', viewMode: 'cards', folders: [{ path: repo, kind: 'repo' }] } }));
@@ -127,6 +127,27 @@ try {
127127
await win.click('#cards .provider-open-primary');
128128
await win.waitForFunction(() => [...document.querySelectorAll('.ck-term')].some((e) => e.textContent.includes('DEVDECK_QA_PTY_READY')));
129129
assert.equal(await win.locator('.ck-term.show').count(), 1);
130+
await win.locator('.ck-term.show .xterm-helper-textarea').press('Control+f');
131+
await win.fill('.ck-find-input', 'DEVDECK_QA_HISTORY_START');
132+
await win.waitForFunction(() => document.querySelector('.ck-term.show .xterm-rows')?.textContent.includes('DEVDECK_QA_HISTORY_START'));
133+
await win.locator('.ck-find-input').press('Escape');
134+
await win.screenshot({ path: join(out, 'terminal-history.png') });
135+
// Navigate from a scrolled long view, then focus/search the terminal and resize the window.
136+
// Measure the top as well as bottom: fitting inside a tile alone misses an off-screen tile.
137+
await view('settings');
138+
await win.evaluate(() => { document.getElementById('content').scrollTop = 600; });
139+
await win.locator('#shell-session-groups .shell-session').first().click();
140+
for (const size of [[800, 600], [1280, 800]]) {
141+
await app.evaluate(({ BrowserWindow }, size) => BrowserWindow.getAllWindows()[0].setSize(...size), size);
142+
await win.waitForFunction(() => {
143+
const content = document.getElementById('content').getBoundingClientRect();
144+
const header = document.getElementById('ck-header').getBoundingClientRect();
145+
const tile = document.querySelector('.ck-term.show').getBoundingClientRect();
146+
const screen = document.querySelector('.ck-term.show .xterm-screen').getBoundingClientRect();
147+
return header.top >= content.top && tile.top >= header.bottom && screen.top >= tile.top
148+
&& screen.bottom <= content.bottom && screen.right <= content.right;
149+
});
150+
}
130151
await view('projects');
131152
});
132153

@@ -158,18 +179,43 @@ try {
158179
await check('machine switch immediately removes old actionable rows; A → B → A ignores old success', async () => {
159180
await win.click('#refresh'); await pending('local-projects');
160181
await win.selectOption('#machine-switch', 'qa-remote'); await pending('remote-projects');
182+
assert.match(await win.locator('#project-load-status').innerText(), /Loading projects from QA Remote/);
183+
assert.equal(await win.locator('#cards').getAttribute('aria-busy'), 'true');
184+
await win.waitForFunction(() => document.querySelector('#project-load-status')?.textContent.includes('longer than usual'), { }, { timeout: 12_000 });
185+
await win.screenshot({ path: join(out, 'slow-machine-loading.png') });
161186
assert.equal(await win.locator('#cards .card').count(), 0);
162187
assert.equal(await win.locator('#shell-projects .shell-project').count(), 0);
163188
await win.selectOption('#machine-switch', 'local'); await pending('local-projects', 2);
189+
assert.match(await win.locator('#project-load-status').innerText(), /Loading projects from This PC/);
190+
// An obsolete answer must not clear the current machine's loading indication.
191+
await answer('remote-projects', { fail: true });
192+
assert.equal(await win.locator('#cards').getAttribute('aria-busy'), 'true');
164193
await answer('local-projects', { index: 1, name: 'Newest local' });
165194
await win.waitForFunction(() => document.querySelector('#cards')?.textContent.includes('Newest local'));
166195
await answer('local-projects', { name: 'Obsolete local' });
167-
await answer('remote-projects', { fail: true });
196+
assert.equal(await win.locator('#project-load-status').isVisible(), false);
197+
assert.equal(await win.locator('#cards').getAttribute('aria-busy'), 'false');
168198
assert.ok((await win.locator('#cards').innerText()).includes('Newest local'));
169199
assert.ok(!(await win.locator('#cards').innerText()).includes('Obsolete local'));
170200
assert.equal(await win.locator('#cards .load-error').count(), 0);
171201
});
172202

203+
await check('failed machine load ends the busy state and retry shows progress through success', async () => {
204+
await win.selectOption('#machine-switch', 'qa-remote');
205+
await answer('remote-projects', { fail: true });
206+
await win.waitForSelector('#cards .load-error-retry');
207+
assert.equal(await win.locator('#project-load-status').isVisible(), false);
208+
assert.equal(await win.locator('#cards').getAttribute('aria-busy'), 'false');
209+
await win.click('#cards .load-error-retry');
210+
await pending('remote-projects');
211+
assert.equal(await win.locator('#project-load-status').isVisible(), true);
212+
await answer('remote-projects', { name: 'Recovered remote' });
213+
await win.waitForFunction(() => document.querySelector('#cards')?.textContent.includes('Recovered remote'));
214+
assert.equal(await win.locator('#project-load-status').isVisible(), false);
215+
await win.selectOption('#machine-switch', 'local');
216+
await answer('local-projects', { name: 'Returned local' });
217+
});
218+
173219
await check('task board ignores both obsolete success and obsolete failure after repeated navigation', async () => {
174220
await view('next'); await pending('local-projects');
175221
await view('projects'); await view('next'); await pending('local-projects', 2);

‎src/main/ptyHost.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('what a machine can say about its own sessions', () => {
122122
host.create('s', 'pwsh', [], 'C:\repo', 80, 24, () => {}, () => {});
123123
f.emit('first line\nbuilding…\n');
124124
f.emit('done\n');
125-
expect(host.buffer('s').data).toContain('done');
125+
expect(host.buffer('s').data).toBe('first line\nbuilding…\ndone\n');
126126
});
127127

128128
it('drops the OLDEST output when the buffer fills — the recent part is what matters', () => {
@@ -146,6 +146,21 @@ describe('what a machine can say about its own sessions', () => {
146146
expect(host.buffer('s')).toEqual({ data: expect.stringContaining('hello'), cols: 120, rows: 40 });
147147
});
148148

149+
it('does not replay a truncated escape prefix until a complete line boundary exists', () => {
150+
const f = fake();
151+
const host = new PtyHost(() => f.proc);
152+
host.create('s', 'pwsh', [], 'C:/repo', 80, 24, () => {}, () => {});
153+
f.emit('x'.repeat(300_000));
154+
expect(host.buffer('s').data).toBe('');
155+
f.emit('\x1b[Hfull screen repaint');
156+
expect(host.buffer('s').data).toBe('\x1b[Hfull screen repaint');
157+
f.emit('\ncomplete line\n');
158+
expect(host.buffer('s').data).toBe('complete line\n');
159+
host.resize('s', 100, 30);
160+
f.emit('\x1b[Hfirst repaint row\nsecond row\n');
161+
expect(host.buffer('s').data).toBe('\x1b[Hfirst repaint row\nsecond row\n');
162+
});
163+
149164
it('forgets output drawn at the old size when the size changes', () => {
150165
// The bug this exists for: ConPTY paints by absolute cursor address, computed for the width it
151166
// had at the time. Handing a viewer bytes drawn at 120 columns to replay into an 80-column

‎src/main/ptyHost.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ interface Session {
8383
/** Chunks, oldest first, trimmed from the front once the total passes the cap. */
8484
chunks: string[];
8585
bufferedLength: number;
86+
/** Only a truncated prefix needs discarding through the next complete line. */
87+
prefixTruncated: boolean;
8688
}
8789

8890
export class PtyHost {
@@ -118,6 +120,7 @@ export class PtyHost {
118120
internal: info?.internal === true,
119121
chunks: [],
120122
bufferedLength: 0,
123+
prefixTruncated: false,
121124
};
122125
this.sessions.set(id, session);
123126
proc.onData((data) => { this.remember(session, data); onData(data); });
@@ -134,12 +137,14 @@ export class PtyHost {
134137
session.bufferedLength += data.length;
135138
while (session.bufferedLength > SCROLLBACK_BYTES && session.chunks.length > 1) {
136139
session.bufferedLength -= session.chunks.shift()!.length;
140+
session.prefixTruncated = true;
137141
}
138142
// A single chunk larger than the cap still has to shrink, and it is cut from the FRONT so the most
139143
// recent output — the part a viewer actually needs to see — is what survives.
140144
if (session.bufferedLength > SCROLLBACK_BYTES && session.chunks.length === 1) {
141145
session.chunks[0] = session.chunks[0].slice(-SCROLLBACK_BYTES);
142146
session.bufferedLength = session.chunks[0].length;
147+
session.prefixTruncated = true;
143148
}
144149
}
145150

@@ -171,15 +176,19 @@ export class PtyHost {
171176
/**
172177
* Recent output, for repainting a terminal that is being attached to mid-flight.
173178
*
174-
* Trimmed to the first line boundary: the cut point is arbitrary, and starting a replay in the
175-
* middle of an escape sequence makes xterm render the tail of it as literal text.
179+
* Only a buffer that exceeded the cap is trimmed to a safe boundary. Discarding a first line from
180+
* an intact buffer loses real output (and the cursor-positioning prefix of a ConPTY repaint).
176181
*/
177182
buffer(id: string): PtyScreen {
178183
const session = this.sessions.get(id);
179184
if (!session) return { data: '', cols: 0, rows: 0 };
180185
const joined = session.chunks.join('');
181186
const firstBreak = joined.indexOf('\n');
182-
const data = firstBreak >= 0 && firstBreak < joined.length - 1 ? joined.slice(firstBreak + 1) : joined;
187+
// Full-screen TUIs can repaint using cursor addresses without any newline. Resume at an escape
188+
// boundary in that case, instead of returning a blank screen for the rest of their lifetime.
189+
const firstEscape = joined.indexOf('\x1b');
190+
const data = !session.prefixTruncated ? joined : firstBreak >= 0 ? joined.slice(firstBreak + 1)
191+
: firstEscape >= 0 ? joined.slice(firstEscape) : '';
183192
return { data, cols: session.cols, rows: session.rows };
184193
}
185194

@@ -205,6 +214,7 @@ export class PtyHost {
205214
session.rows = rows;
206215
session.chunks = [];
207216
session.bufferedLength = 0;
217+
session.prefixTruncated = false;
208218
session.proc.resize(cols, rows);
209219
return true;
210220
}

‎src/renderer/cockpitView.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ async function buildTile(p: OpenReq): Promise<boolean> {
652652
el.classList.add('show');
653653
// No cursor blink: it is a repaint twice a second per visible terminal for the life of the app,
654654
// and the spinner beside the input already says whether the agent is busy.
655-
const term = new Terminal({ fontFamily: 'Cascadia Mono, Consolas, monospace', fontSize: 12, theme: { background: '#0a0b0e' }, cursorBlink: false, windowsPty });
655+
const term = new Terminal({ fontFamily: 'Cascadia Mono, Consolas, monospace', fontSize: 12, theme: { background: '#0a0b0e' }, cursorBlink: false, scrollback: 10_000, windowsPty });
656656
const fit = new FitAddon(); term.loadAddon(fit); term.open(el); fit.fit();
657657
const webgl = attachWebgl(term);
658658
el.dataset.termRenderer = webgl ? 'webgl' : 'dom'; // which renderer this tile got — read by the perf harness and by anyone debugging a slow machine

‎src/renderer/features/projects/projects.css‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,16 @@
184184
border-color: var(--dd-accent, var(--dd-border-strong));
185185
font-weight: 560;
186186
}
187+
.project-load-status {
188+
display: flex; flex-basis: 100%; align-items: center; gap: 10px; padding: 12px 14px;
189+
border: 1px solid var(--border-strong); border-radius: 8px;
190+
color: var(--text-dim); background: var(--surface); overflow-wrap: anywhere;
191+
}
192+
.project-load-status.hidden { display: none; }
193+
.project-load-status::before {
194+
content: ''; flex: 0 0 14px; width: 14px; height: 14px; border-radius: 50%;
195+
border: 2px solid var(--border-strong); border-top-color: var(--accent-hover);
196+
animation: project-load-spin .8s linear infinite;
197+
}
198+
@keyframes project-load-spin { to { transform: rotate(360deg); } }
199+
@media (prefers-reduced-motion: reduce) { .project-load-status::before { animation: none; } }

0 commit comments

Comments
 (0)