From 756a53e8fc5b11e96a633d4658dde3f15d110390 Mon Sep 17 00:00:00 2001
From: Jaeman-Lee <57593516+Jaeman-Lee@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:33:24 +0900
Subject: [PATCH 1/4] fix: keep mobile writing local and add Ctrl+Enter send
---
docs/ubuntu-web-desktop.md | 34 ++++++
remote-desktop/mobile-input/mobile-input.js | 51 ++++++++-
remote-desktop/mobile-input/toolbar.html | 10 +-
remote-desktop/tests/mobile-input.test.cjs | 114 +++++++++++++++++++-
4 files changed, 198 insertions(+), 11 deletions(-)
diff --git a/docs/ubuntu-web-desktop.md b/docs/ubuntu-web-desktop.md
index 9d362a0..e9f2d28 100644
--- a/docs/ubuntu-web-desktop.md
+++ b/docs/ubuntu-web-desktop.md
@@ -106,6 +106,40 @@ PC 클립보드는 작성한 문장으로 바뀐다. 문장은 연결 화면을
글쓰기 방식은 기기에서 조합을 끝낸 문장을 클립보드로 보내 이 문제를 피한다.
PC의 전역 한/영 단축키는 임의로 변경하지 않는다.
+### 글쓰기 포커스 보호와 Ctrl+Enter — 2026-09-28
+
+요구사항·진행 상태: [이슈 #22](https://github.com/Jaeman-Lee/dotfiles/issues/22).
+**소스 수정·검증 완료, 운영 적용 대기.** 현재 원격 접속을 끊는 웹 컨테이너 재시작은 아직 수행하지 않았다.
+아래 동작은 변경본을 적용하고 기기 페이지를 새로고침한 뒤 사용할 수 있다.
+
+- **글쓰기**를 누르면 표시를 동기적으로 갱신한 뒤 흰 입력칸에 초점을 둔다.
+- 글쓰기 모드에서는 입력칸이 잠깐 초점을 잃어도 물리 키 이벤트를 PC로 전달하지 않는다.
+ Guacamole의 숨은 입력칸이 초점을 가져가려 하면 흰 입력칸으로 되돌린다.
+ PC 화면 클릭과 대상 선택은 가능하다. 직접 PC에 타이핑하려면 **접기**를 누른다.
+- 흰 입력칸의 **Ctrl+Enter**는 **전송 ↵**과 같다. **Enter**는 줄바꿈이다.
+ 조합 중인 한글, 빈 문장, 키 자동 반복으로는 전송하지 않는다. 전송 중 연타는 기존 전송 잠금을 따른다.
+- 버튼 전송과 붙여넣기 후에도 입력칸에 초점을 두어 다음 문장을 이어서 작성할 수 있다.
+
+원인과 수정: 이전 코드는 textarea에 실제 초점이 있을 때만 키 전달을 막았다.
+입력칸 표시에는 `ng-show`가 사용되어 표시 전환과 동기적인 `focus()`를 분리하지 않았다.
+변경본은 애니메이션 없는 `ng-style` 표시와 모드 전체의 입력 차단을 사용한다.
+숨은 InputSink의 focus 핸들러는 비동기 select를 예약하므로, focus 캡처 단계에서 차단해
+반복 재포커스가 발생하지 않도록 했다. 또한 `ng-if` 내부의 접기 버튼이 자식 scope에
+`writing`을 따로 생성하여 실제 글쓰기 모드를 끄지 못하는 문제를 브라우저에서 재현했고,
+directive scope의 함수로 상태를 바꾸도록 수정했다. iPad에서 보고된 간헐 현상의 정확한
+이벤트 순서는 아직 실기기에서 계측하지 않았으므로 모든 원인을 확정한 것으로 보지 않는다.
+
+검증: Node 회귀 테스트 17개 통과. 실제 Guacamole 1.6.0 Keyboard/InputSink와 Angular를
+사용한 별도 브라우저 fixture에서 Ctrl+Enter의 클립보드→붙여넣기→Enter 순서,
+전송 후 비우기, Enter 줄바꿈, 조합 중 전송 차단, 초점 이탈 시 원격 키 차단,
+숨은 입력칸의 포커스 회수와 접기 후 직접 키 입력 재개를 확인했다.
+이 fixture는 실제 PC 터미널에 연결하지 않는다. 1024px 브라우저 표시도 확인했으나
+iPad Safari 실기기 및 변경본의 RDP 종단간 전송은 별도 확인이 남아 있다.
+
+사용자가 이번 수정 전 기존 **글쓰기→전송** 경로에서 긴 한글 문장의 정상 수신을 확인했다.
+이는 [앞선 진단](ipad-input-session-diagnostics-20260928.md)의 기본 한글 전송 확인을 갱신한다.
+기존 Codex 세 세션의 연결 재시도는 별도 문제로, 이 입력 UI 변경의 해결 범위에 포함하지 않는다.
+
갤럭시와 iPad 모두 주소를 즐겨찾기에 저장하면 다음 접속이 간단하다.
두 기기에서 동시에 연결하는 기능은 별도 검증하지 않았으므로 우선 한 기기씩 사용한다.
diff --git a/remote-desktop/mobile-input/mobile-input.js b/remote-desktop/mobile-input/mobile-input.js
index b6fbebb..8de292e 100644
--- a/remote-desktop/mobile-input/mobile-input.js
+++ b/remote-desktop/mobile-input/mobile-input.js
@@ -13,9 +13,26 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout',
if (scope.writing) scope.menu.inputMethod = 'none';
var draft = element[0].querySelector('#ubuntu-writing-text');
+ var composing = false;
+ function compositionStarted() { composing = true; }
+ function compositionEnded() { composing = false; }
+ draft.addEventListener('compositionstart', compositionStarted);
+ draft.addEventListener('compositionend', compositionEnded);
+
// The native keyboard/IME edits this local draft. Never forward its
// physical key events to the remote client as well.
- function localKey(event) { event.stopPropagation(); }
+ function localKey(event) {
+ event.stopPropagation();
+ if (event.type === 'keydown' && event.key === 'Enter' && event.ctrlKey &&
+ !event.altKey && !event.metaKey && !event.shiftKey &&
+ !composing && !event.isComposing && event.keyCode !== 229) {
+ event.preventDefault();
+ // An empty/repeated shortcut must never execute a bare PC Enter.
+ if (!event.repeat && scope.draft) {
+ scope.$apply(function () { scope.pasteDraft(true); });
+ }
+ }
+ }
['keydown', 'keyup', 'keypress'].forEach(function (name) {
draft.addEventListener(name, localKey);
});
@@ -24,10 +41,28 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout',
// local form editing out of the remote desktop.
['guacBeforeKeydown', 'guacBeforeKeyup'].forEach(function (name) {
scope.$on(name, function (event) {
- if ($window.document.activeElement === draft) event.preventDefault();
+ // Focus can briefly land on Guacamole's hidden input sink,
+ // especially after a touch or browser focus transition.
+ // Fail closed for the entire writing mode, not just textarea focus.
+ if (scope.writing || $window.document.activeElement === draft)
+ event.preventDefault();
});
});
+ function protectFocus(event) {
+ if (!scope.writing || scope.menu.shown || event.target === draft) return;
+ var target = event.target;
+ if (target.tagName !== 'TEXTAREA') return;
+ var rect = target.getBoundingClientRect();
+ if (target.id === 'ubuntu-remote-text' || (!rect.width && !rect.height)) {
+ // Stop before InputSink's target focus listener schedules a
+ // select()/refocus timer, or the two fields can fight forever.
+ event.stopImmediatePropagation();
+ draft.focus();
+ }
+ }
+ $window.document.addEventListener('focus', protectFocus, true);
+
var writingButton = element[0].querySelector('.ubuntu-open-writing');
function openWriting() {
scope.$apply(function () {
@@ -38,6 +73,12 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout',
draft.focus();
}
writingButton.addEventListener('click', openWriting);
+ scope.closeWriting = function () {
+ // The button lives inside ng-if's child scope. Change the
+ // directive scope here instead of shadowing `writing` there.
+ scope.writing = false;
+ scope.menu.inputMethod = 'none';
+ };
var pendingPaste, pendingEnter;
var lastPaste = null;
@@ -55,6 +96,9 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout',
return;
}
scope.sending = true;
+ // Keep typing locally after clicking Send/Paste, including on iPad
+ // where opening the keyboard needs the original user gesture.
+ if (scope.writing) draft.focus();
var client = managed.client;
var terminal = scope.terminal;
var text = scope.draft;
@@ -153,6 +197,9 @@ angular.module('client').directive('ubuntuMobileInput', ['$window', '$timeout',
scope.$on('$destroy', function () {
button.removeEventListener('click', openKeyboard);
writingButton.removeEventListener('click', openWriting);
+ $window.document.removeEventListener('focus', protectFocus, true);
+ draft.removeEventListener('compositionstart', compositionStarted);
+ draft.removeEventListener('compositionend', compositionEnded);
['keydown', 'keyup', 'keypress'].forEach(function (name) {
draft.removeEventListener(name, localKey);
});
diff --git a/remote-desktop/mobile-input/toolbar.html b/remote-desktop/mobile-input/toolbar.html
index fbde67a..a1823f9 100644
--- a/remote-desktop/mobile-input/toolbar.html
+++ b/remote-desktop/mobile-input/toolbar.html
@@ -3,26 +3,26 @@
-
+
-
+
-
-
+
+
-
{{notice || '한/영은 기기 키보드에서 전환 · PC 클립보드를 사용합니다.'}}
+
{{notice || '글쓰기 중에는 PC 직접 타이핑이 잠깁니다. 직접 입력하려면 접기를 누르세요.'}}
diff --git a/remote-desktop/tests/mobile-input.test.cjs b/remote-desktop/tests/mobile-input.test.cjs
index fe7d55f..e4d7984 100644
--- a/remote-desktop/tests/mobile-input.test.cjs
+++ b/remote-desktop/tests/mobile-input.test.cjs
@@ -6,7 +6,34 @@ const path = require('node:path');
function harness() {
const calls = [], timers = [], listeners = {};
- const element = {addEventListener() {}, removeEventListener() {}, focus() {}};
+ const documentListeners = {};
+ const document = {
+ activeElement: null,
+ addEventListener(name, fn) { documentListeners[name] = fn; },
+ removeEventListener(name) { delete documentListeners[name]; },
+ getElementById() { return remote; }
+ };
+ function node(id) {
+ const events = {};
+ return {
+ id, tagName: 'TEXTAREA', events,
+ addEventListener(name, fn) { events[name] = fn; },
+ removeEventListener(name) { delete events[name]; },
+ focus() { document.activeElement = this; },
+ getBoundingClientRect() { return {width: 100, height: 70}; },
+ fire(type, options = {}) {
+ const event = {type, defaultPrevented: false, stopped: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() { this.stopped = true; }, ...options};
+ if (events[type]) events[type](event);
+ return event;
+ }
+ };
+ }
+ const draft = node('ubuntu-writing-text'), writing = node('writing'),
+ keyboard = node('keyboard'), remote = node('ubuntu-remote-text');
+ const nodes = {'#ubuntu-writing-text': draft, '.ubuntu-open-writing': writing,
+ '.ubuntu-open-keyboard': keyboard};
const scope = {
menu: {},
$on(name, fn) { listeners[name] = fn; },
@@ -21,15 +48,15 @@ function harness() {
let directive;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../mobile-input/mobile-input.js'), 'utf8'), {
angular: {module: () => ({directive: (name, deps) => {
- directive = deps.at(-1)({navigator: {maxTouchPoints: 1}, document: {}}, timeout);
+ directive = deps.at(-1)({navigator: {maxTouchPoints: 1}, document}, timeout);
}})},
Guacamole: {StringWriter: function () {
this.sendText = text => calls.push(['clipboard', text]);
this.sendEnd = () => calls.push(['end']);
}}
});
- directive.link(scope, [{querySelector: () => element}]);
- return {scope, calls, listeners, step() {
+ directive.link(scope, [{querySelector: selector => nodes[selector]}]);
+ return {scope, calls, listeners, draft, writing, keyboard, remote, document, documentListeners, step() {
const timer = timers.shift();
if (timer && !timer.cancelled) timer.fn();
}};
@@ -103,3 +130,82 @@ test('leaving the view cancels pending Enter', () => {
h.scope.sendEnter(); h.step(); h.listeners.$destroy(); h.step();
assert.equal(h.calls.some(c => c[2] === 0xff0d), false);
});
+
+test('opening writing restores the local draft focus', () => {
+ const h = harness(); h.scope.writing = false; h.scope.menu.inputMethod = 'text';
+ h.remote.focus(); h.writing.fire('click');
+ assert.equal(h.scope.writing, true);
+ assert.equal(h.scope.menu.inputMethod, 'none');
+ assert.equal(h.document.activeElement, h.draft);
+});
+test('writing blocks remote capture even after draft loses focus', () => {
+ const h = harness(); h.document.activeElement = h.remote;
+ for (const name of ['guacBeforeKeydown', 'guacBeforeKeyup']) {
+ let prevented = false;
+ h.listeners[name]({preventDefault() { prevented = true; }});
+ assert.equal(prevented, true);
+ }
+ assert.deepEqual(h.calls, []);
+});
+test('hidden sink and legacy textarea cannot steal writing focus', () => {
+ const h = harness();
+ for (const target of [h.remote, {tagName: 'TEXTAREA',
+ getBoundingClientRect() { return {width: 0, height: 0}; }}]) {
+ h.document.activeElement = target;
+ let stopped = false;
+ h.documentListeners.focus({target, stopImmediatePropagation() { stopped = true; }});
+ assert.equal(stopped, true);
+ assert.equal(h.document.activeElement, h.draft);
+ }
+ const select = {tagName: 'SELECT'};
+ h.document.activeElement = select; h.documentListeners.focus({target: select});
+ assert.equal(h.document.activeElement, select);
+});
+test('collapsing or selecting legacy keyboard restores direct input', () => {
+ const h = harness();
+ // ng-if calls the inherited function from a child scope.
+ const child = Object.create(h.scope); child.closeWriting();
+ assert.equal(h.scope.writing, false);
+ h.document.activeElement = h.remote;
+ let prevented = false;
+ h.listeners.guacBeforeKeydown({preventDefault() { prevented = true; }});
+ assert.equal(prevented, false);
+ h.scope.writing = true; h.keyboard.fire('click');
+ assert.equal(h.scope.writing, false);
+ assert.equal(h.document.activeElement, h.remote);
+});
+test('Ctrl+Enter sends once and retains ordinary Enter for local newlines', () => {
+ const h = harness(); h.scope.draft = '한글 shortcut';
+ const newline = h.draft.fire('keydown', {key: 'Enter'});
+ assert.equal(newline.defaultPrevented, false);
+ assert.equal(newline.stopped, true);
+ assert.deepEqual(h.calls, []);
+ const shortcut = h.draft.fire('keydown', {key: 'Enter', ctrlKey: true});
+ assert.equal(shortcut.defaultPrevented, true);
+ h.draft.fire('keydown', {key: 'Enter', ctrlKey: true});
+ h.step(); h.step();
+ assert.equal(h.calls.filter(c => c[0] === 'clipboard').length, 1);
+ assert.deepEqual(h.calls.slice(-2), enter);
+ assert.equal(h.document.activeElement, h.draft);
+});
+test('Ctrl+Enter never sends while IME is composing or on key repeat', () => {
+ const h = harness(); h.scope.draft = '조합 중';
+ h.draft.fire('compositionstart');
+ h.draft.fire('keydown', {key: 'Enter', ctrlKey: true});
+ h.draft.fire('compositionend');
+ for (const options of [{isComposing: true}, {keyCode: 229}, {repeat: true}])
+ h.draft.fire('keydown', {key: 'Enter', ctrlKey: true, ...options});
+ assert.deepEqual(h.calls, []);
+ h.draft.fire('keydown', {key: 'Enter', ctrlKey: true});
+ assert.equal(h.calls[0][0], 'clipboard');
+});
+test('empty Ctrl+Enter does not execute the remote prompt', () => {
+ const h = harness();
+ assert.equal(h.draft.fire('keydown', {key: 'Enter', ctrlKey: true}).defaultPrevented, true);
+ assert.deepEqual(h.calls, []);
+});
+test('focus protection is removed when the view is destroyed', () => {
+ const h = harness(); h.listeners.$destroy();
+ assert.equal(h.documentListeners.focus, undefined);
+ assert.deepEqual(Object.keys(h.draft.events), []);
+});
From 04ec0dfae9dbfec9ca17946d3fac2b6b1b1f21ee Mon Sep 17 00:00:00 2001
From: Jaeman-Lee <57593516+Jaeman-Lee@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:34:22 +0900
Subject: [PATCH 2/4] docs: link writing input fix review and verification
---
docs/ubuntu-web-desktop.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/ubuntu-web-desktop.md b/docs/ubuntu-web-desktop.md
index e9f2d28..2be6767 100644
--- a/docs/ubuntu-web-desktop.md
+++ b/docs/ubuntu-web-desktop.md
@@ -109,6 +109,8 @@ PC의 전역 한/영 단축키는 임의로 변경하지 않는다.
### 글쓰기 포커스 보호와 Ctrl+Enter — 2026-09-28
요구사항·진행 상태: [이슈 #22](https://github.com/Jaeman-Lee/dotfiles/issues/22).
+변경 검토: [draft PR #23](https://github.com/Jaeman-Lee/dotfiles/pull/23),
+구현·검증: [커밋 756a53e](https://github.com/Jaeman-Lee/dotfiles/commit/756a53e).
**소스 수정·검증 완료, 운영 적용 대기.** 현재 원격 접속을 끊는 웹 컨테이너 재시작은 아직 수행하지 않았다.
아래 동작은 변경본을 적용하고 기기 페이지를 새로고침한 뒤 사용할 수 있다.
From 0791a6fa4cad1acec0995ac79943c928adda81ac Mon Sep 17 00:00:00 2001
From: Jaeman-Lee <57593516+Jaeman-Lee@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:22:38 +0900
Subject: [PATCH 3/4] docs: close out remote testing and record reconnect
findings
---
...ipad-input-session-diagnostics-20260928.md | 55 +++++++++++++++++--
docs/ubuntu-web-desktop.md | 2 +
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/docs/ipad-input-session-diagnostics-20260928.md b/docs/ipad-input-session-diagnostics-20260928.md
index 7f82c80..81e4afb 100644
--- a/docs/ipad-input-session-diagnostics-20260928.md
+++ b/docs/ipad-input-session-diagnostics-20260928.md
@@ -8,10 +8,26 @@ vault: 미설정
문서 원본은 이 파일이다. 지정 Obsidian Vault 연결은 미설정이다.
요구사항·진행 상태: [이슈 #20](https://github.com/Jaeman-Lee/dotfiles/issues/20).
+최종 기록·입력 개선 검토: [draft PR #23](https://github.com/Jaeman-Lee/dotfiles/pull/23).
+초기 진단 기록: [커밋 fb25194](https://github.com/Jaeman-Lee/dotfiles/commit/fb25194).
기존 입력 운영 절차는 [Ubuntu 웹 원격 화면](ubuntu-web-desktop.md#ipad)을 따른다.
실행 로그·대화 원문·세션 식별자·인증 정보는 기록하지 않는다.
-## 확인 결과
+## 이번 작업의 마감
+
+2026-09-28 사용자 요청에 따라 이번 원격 테스트와 진단은 여기까지 진행하고 Git 기록을 남긴다.
+운영 배포·서비스 재시작·PR 병합은 수행하지 않는다. 해결되지 않은 항목은 이슈에서 후속 보류로 유지한다.
+
+- iPad의 기존 **글쓰기 → 전송** 경로에서 긴 한글 문장이 정상 수신되는 것을 사용자와 확인했다.
+- 사용자는 글쓰기 클릭 후 간헐적으로 PC 터미널로 입력되는 현상도 보고했다.
+ 포커스 보호·접기 수정과 Ctrl+Enter 전송은 구현 및 검증을 마쳤지만 **운영에는 미적용**이다.
+ 구현·검증 원본은 [운영 문서의 변경 기록](ubuntu-web-desktop.md#글쓰기-포커스-보호와-ctrlenter--2026-09-28)을 따른다.
+- 직접 입력의 편의성을 우선하고 글쓰기는 보조 수단으로 남기는 방향을 사용자가 선호했다.
+ 직접 입력 개선은 구현하지 않았다. 실제 브라우저와 외장/화면 키보드 구분도 확인이 남았다.
+- 빨간 `Reconnecting…`에 대응하는 실제 모델 응답 연결 종료 및 재시도 기록을 확인했다.
+ 아래 두 건은 후속 작업 완료까지 확인했으나, 모든 세션의 안정성이나 반복 장애의 근본 원인이 해결된 것은 아니다.
+
+## 초기 확인 결과
14:47–14:51 KST에 읽기 전용으로 확인했다. 해당 시점의 관측이며 간헐적인 장애를 배제하지 않는다.
@@ -31,16 +47,43 @@ vault: 미설정
기존 운영 문서에는 Guacamole Text input 경로에서 한글 누락과 PC 입력기의 영문 재해석을
실제 호스트에서 재현한 기록이 있다. iPad 사용자는 하단 **글쓰기**의 흰 입력칸에서
문장을 완성하고 **전송 ↵**을 누르는 기존 기능을 우선 사용한다.
-실제 사용 중인 브라우저·원격 앱, 외장/화면 키보드 여부와 이 입력칸에서도 문제가 발생하는지는 확인이 남았다.
+후속 확인에서 이 입력칸을 통한 한글 문장 전송은 성공했다. 간헐적인 포커스 문제는 별도로 보고되었다.
-## 검증과 남은 확인
+## 초기 진단의 검증 범위
`node --test remote-desktop/tests/mobile-input.test.cjs`: 기존 회귀 테스트 9개 통과.
이는 전송 순서·중복 방지·초안 보존 검증이며 iPad 실기기 한글 조합 성공을 증명하지 않는다.
-소스 코드, PC 입력기 설정, 원격 서비스와 Codex 실행 상태는 변경하지 않았다.
+초기 진단에서는 소스 코드, PC 입력기 설정, 원격 서비스와 Codex 실행 상태를 변경하지 않았다.
기존 세션이나 daemon을 재시작하지 않았다. 미전송 입력과 실행 중 작업을 보존했다.
-실제 오류 문구와 영향받는 세션의 연결 상태를 확인한 뒤 복구 범위를 결정한다.
-한글 입력 문제와 세션 연결 문제 모두 해결 완료로 처리하지 않는다.
+후속 소스 수정과 17개 회귀 테스트 결과는 운영 문서에 기록했으며, 운영 서비스는 계속 유지했다.
+
+## Codex 재접속 후속 확인
+
+2026-09-28 22:12–22:17 KST에 보존된 로그와 작업 완료 이벤트를 읽기 전용으로 확인했다.
+다음 시각은 모두 KST이며, 확인 가능한 사례만 기술한다.
+
+| 시각 | 관측 | 확인 범위 |
+| --- | --- | --- |
+| 09-27 19:57:36, 20:58:13 | 응답 WebSocket의 60분 연결 제한에 도달했다는 오류 후 재시도 | 이 두 건의 재접속 사유가 오류에 명시되어 있다. |
+| 09-28 18:30:18 | 응답 완료 전 `Connection closed normally`로 연결 종료, 재시도 1/5 | 같은 대화의 작업 완료 이벤트가 18:30:52에 기록되어 약 34초 뒤 완료를 확인했다. |
+| 09-28 19:38:04 | 같은 연결 종료와 재시도 1/5 | 작업 완료 이벤트가 19:38:18에 기록되어 약 14초 뒤 완료를 확인했다. |
+| 09-28 22:11:48, 22:11:57 | 로컬 제어 소켓의 WebSocket handshake 미완료 | 앞선 모델 서버 응답 연결 종료와 동일한 원인이라고 단정하지 않는다. |
+
+`Connection closed normally`는 연결 종료 방식의 설명이며, 답변 생성이 성공적으로 끝났다는 의미가 아니다.
+오늘 두 건에서 연결을 닫은 근본 원인이 서버인지 네트워크인지 로그만으로 확정할 수 없다.
+별도로 여러 터미널에서 `account/rateLimits/read` 시간 초과가 반복되었다.
+이는 조회 응답이 지연된 기록이며, 사용량 한도를 소진했다는 증거는 아니다.
+
+원격 웹 컨테이너는 확인 당시 약 23시간 연속 실행 중이었고 OOM 종료 흔적은 없었다.
+GNOME 원격 서비스도 유지되어 있었다. 이번 작업에서 원격 웹·Codex daemon을 재시작하거나
+수정본을 운영에 적용한 사실은 없다. 이 관측만으로 과거의 모든 네트워크 끊김을 배제하지 않는다.
+
+## 후속 작업의 경계
+
+실제 할 일과 상태는 [이슈 #20](https://github.com/Jaeman-Lee/dotfiles/issues/20) 및
+[입력 개선 이슈 #22](https://github.com/Jaeman-Lee/dotfiles/issues/22)에서 관리한다.
+다시 진행할 때는 운영 적용 여부를 먼저 정하고, 적용한다면 기기 새로고침 후 iPad 실기기 포커스·단축키를 확인한다.
+직접 한글 입력 개선과 반복 재접속의 근본 원인 조사는 별도 후속 범위다.
참고: [OpenAI 공식 설정 문서](https://developers.openai.com/codex/config-reference/).
diff --git a/docs/ubuntu-web-desktop.md b/docs/ubuntu-web-desktop.md
index 2be6767..5ee0730 100644
--- a/docs/ubuntu-web-desktop.md
+++ b/docs/ubuntu-web-desktop.md
@@ -112,6 +112,8 @@ PC의 전역 한/영 단축키는 임의로 변경하지 않는다.
변경 검토: [draft PR #23](https://github.com/Jaeman-Lee/dotfiles/pull/23),
구현·검증: [커밋 756a53e](https://github.com/Jaeman-Lee/dotfiles/commit/756a53e).
**소스 수정·검증 완료, 운영 적용 대기.** 현재 원격 접속을 끊는 웹 컨테이너 재시작은 아직 수행하지 않았다.
+사용자 요청으로 이번 테스트는 여기서 마감하고 운영 적용과 실기기 후속 확인은 보류한다.
+최종 수신 확인·재접속 진단과 작업 범위는 [원격 테스트 마감 기록](ipad-input-session-diagnostics-20260928.md)에 남긴다.
아래 동작은 변경본을 적용하고 기기 페이지를 새로고침한 뒤 사용할 수 있다.
- **글쓰기**를 누르면 표시를 동기적으로 갱신한 뒤 흰 입력칸에 초점을 둔다.
From 6ef4506affc9d6834c4cc73b5973a654975176d0 Mon Sep 17 00:00:00 2001
From: Jaeman-Lee <57593516+Jaeman-Lee@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:25:47 +0900
Subject: [PATCH 4/4] docs: distinguish captured app-server restore failure
from stream retries
---
...ipad-input-session-diagnostics-20260928.md | 26 +++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/docs/ipad-input-session-diagnostics-20260928.md b/docs/ipad-input-session-diagnostics-20260928.md
index 81e4afb..f55dc56 100644
--- a/docs/ipad-input-session-diagnostics-20260928.md
+++ b/docs/ipad-input-session-diagnostics-20260928.md
@@ -24,8 +24,9 @@ vault: 미설정
구현·검증 원본은 [운영 문서의 변경 기록](ubuntu-web-desktop.md#글쓰기-포커스-보호와-ctrlenter--2026-09-28)을 따른다.
- 직접 입력의 편의성을 우선하고 글쓰기는 보조 수단으로 남기는 방향을 사용자가 선호했다.
직접 입력 개선은 구현하지 않았다. 실제 브라우저와 외장/화면 키보드 구분도 확인이 남았다.
-- 빨간 `Reconnecting…`에 대응하는 실제 모델 응답 연결 종료 및 재시도 기록을 확인했다.
- 아래 두 건은 후속 작업 완료까지 확인했으나, 모든 세션의 안정성이나 반복 장애의 근본 원인이 해결된 것은 아니다.
+- 모델 응답 연결 종료 및 재시도 기록과 두 작업의 후속 완료를 확인했다.
+ 다만 이후 사용자가 제공한 캡처는 별도의 **app-server 연결 상실 및 세션 자동 복원 실패**였다.
+ 아래 두 작업의 완료를 캡처 속 세션 복구의 증거로 사용할 수 없다.
## 초기 확인 결과
@@ -79,6 +80,27 @@ vault: 미설정
GNOME 원격 서비스도 유지되어 있었다. 이번 작업에서 원격 웹·Codex daemon을 재시작하거나
수정본을 운영에 적용한 사실은 없다. 이 관측만으로 과거의 모든 네트워크 끊김을 배제하지 않는다.
+### 사용자 캡처 확인에 따른 정정
+
+사용자가 안내한 로컬 Screenshots 폴더의 2026-09-28 22:23:46 캡처를 직접 확인했다.
+캡처 원본과 화면의 개인 작업 내용은 Git에 포함하지 않고 진단에 필요한 오류 문구만 기록한다.
+
+- `Connection lost. Attempting to reconnect…`
+- `Automatic reconnect could not restore this session.`
+- `app-server session could not be restored`
+- 화면에는 Ctrl+C 후 같은 명령으로 재연결하라는 안내도 표시되어 있다.
+
+이 오류는 Codex 터미널과 app-server 사이의 연결을 잃은 뒤 기존 세션을 자동 복원하지
+못한 상태를 보여 준다. 앞서 모델 응답 스트림의 재시도만으로 사용자 증상을 설명한 것은
+불충분했다. 단순 모델 응답 재시도와 터미널 세션 자동 복원 실패를 구분하여 후속 조사해야 한다.
+캡처만으로 대화 기록 삭제나 작업 유실 여부를 판단할 수 없다.
+
+현재 daemon 프로세스 시작 시각은 09-28 14:25:18 KST이며 CLI 0.157.0 / daemon 0.158.0
+조합을 다시 확인했다. 22:20:34와 22:24:05에는 app-server WebSocket의 closing handshake
+없는 connection reset도 기록되었다. 캡처 시각이 실제 최초 장애 시각과 같다고 볼 수 없으므로
+이 시각·버전 차이·주변 연결 오류를 곧바로 해당 실패의 원인으로 단정하지 않는다.
+재시작·업데이트·세션 재연결은 수행하지 않았으며 이번 추가 확인은 진단 기록 정정으로 한정한다.
+
## 후속 작업의 경계
실제 할 일과 상태는 [이슈 #20](https://github.com/Jaeman-Lee/dotfiles/issues/20) 및