diff --git a/apps/chrome/KURULUM.txt b/apps/chrome/KURULUM.txt
index 24a1aa4..7f40420 100644
--- a/apps/chrome/KURULUM.txt
+++ b/apps/chrome/KURULUM.txt
@@ -1,7 +1,7 @@
AITURK Chrome — Windows beta 0.1.0
GEREKSİNİMLER
-Windows 10/11, Chrome 116 veya üzeri, Chrome köprüsü içeren AITURK IDE.
+Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.7 veya üzeri.
Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır.
İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut
konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez.
diff --git a/apps/chrome/README.md b/apps/chrome/README.md
new file mode 100644
index 0000000..a7e31b9
--- /dev/null
+++ b/apps/chrome/README.md
@@ -0,0 +1,52 @@
+# AITURK Chrome beta
+
+Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
+Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup.
+
+The extension talks only to `online.turkservis.aiturk` using Chrome native
+messaging. The per-user .NET Framework host authenticates to Electron's random
+named pipe with a separate capability from the user-local discovery file.
+Electron retains the gateway token, allows only six semantic operations and
+reuses the existing local backend. It resumes the stored conversation, maps its
+runtime identity, then submits only the new prompt. No parallel agent backend,
+history reconstruction, model API key in the browser or telemetry is involved.
+
+Only the active local profile is supported. The list searches titles among
+desktop sessions in the most recent 200 records. The transcript shows the last
+40 records, with up to 8,000 characters per visible message; the model's existing
+context is not truncated by this display policy. Approvals and clarifications
+remain in the IDE. A backend event is stamped once and delivered to every
+attached viewer so the desktop can still show and answer these requests.
+
+## Build and verification
+
+From the repository root, on Windows:
+
+```powershell
+./scripts/build-chrome.ps1 -OutputDirectory C:/path/to/new-output-directory
+$env:AITURK_TEST_NATIVE_HOST = 'C:/path/printed/by/build/AITURK Chrome Kur.exe'
+node --test apps/chrome/tests/bridge.test.mjs
+```
+
+The build uses the Windows .NET Framework C# compiler, not a downloaded binary.
+`AITURK_TEST_NATIVE_HOST` enables the real framing / named-pipe / HTTP / WebSocket
+integration test against a disposable fixture, without installing registry
+entries or invoking a paid model. Without it the native host test is skipped.
+
+UI tests require the existing desktop workspace's jsdom dependency:
+
+```powershell
+npm ci --workspace apps/desktop --include-workspace-root
+node --test apps/chrome/tests/panel.test.mjs
+```
+
+Run backend fan-out and replay tests with the canonical hermetic runner:
+
+```bash
+scripts/run_tests.sh -j 1 --paths tests/tui_gateway/test_chrome_viewer_fanout.py:tests/test_tui_gateway_event_replay.py
+```
+
+Publish an IDE installer from the same clean commit (including the backend
+change), the Chrome ZIP and their SHA-256 checksums together. Update the download
+page only after the immutable release assets are available. This is an unsigned
+developer-mode beta, not a Chrome Web Store or signed Windows release.
diff --git a/apps/chrome/extension/panel.js b/apps/chrome/extension/panel.js
index 2e3debd..8a4ffcb 100644
--- a/apps/chrome/extension/panel.js
+++ b/apps/chrome/extension/panel.js
@@ -3,6 +3,7 @@ import { capturePageContext, composePrompt } from './page-context.js'
const $ = id => document.getElementById(id)
let port, connected = false, sessions = [], selected = '', generation = 0, nextId = 0
let stream, attachment = null, sending = false, opening = false, connectionGeneration = 0
+let bufferedEvents = [], turnGeneration = 0
const pending = new Map()
const notice = text => { $('notice').textContent = text; $('notice').hidden = !text }
const activity = text => { $('activity').textContent = text }
@@ -112,6 +113,7 @@ function paintHistory(messages) {
async function openSession(id) {
const intent = ++generation
selected = id; opening = Boolean(id); stream = null
+ bufferedEvents = []
attachment = null; $('attachment').hidden = true
$('message').value = ''; $('stop').hidden = true
notice(''); controls()
@@ -127,24 +129,31 @@ async function openSession(id) {
if (data.attention) notice('Bu konuşma IDE’de onay veya ek bilgi bekliyor.')
await chrome.storage.local.set({ selectedSession: id })
} catch (error) { if (intent === generation) { selected = ''; notice(error.message) } }
- finally { if (intent === generation) { opening = false; controls() } }
+ finally {
+ if (intent === generation) {
+ opening = false; controls()
+ const events = bufferedEvents; bufferedEvents = []
+ events.forEach(handleEvent)
+ }
+ }
}
async function reconcile() {
- const intent = generation, sessionId = selected
+ const intent = generation, sessionId = selected, turn = turnGeneration
try {
const data = await request('session.history', { sessionId })
- if (intent === generation && sessionId === selected && !opening) paintHistory(data.messages)
+ if (intent === generation && sessionId === selected && !opening && !stream && turn === turnGeneration) paintHistory(data.messages)
} catch (error) { if (intent === generation) notice(error.message) }
}
function handleEvent(frame) {
if (frame.event === 'disconnected') { disconnect(frame.message); return }
if (frame.event === 'error') { notice(frame.message); return }
- if (frame.sessionId !== selected || opening) return
+ if (frame.sessionId !== selected) return
+ if (opening) { if (bufferedEvents.length < 2000) bufferedEvents.push(frame); return }
const nearBottom = $('transcript').scrollHeight - $('transcript').scrollTop - $('transcript').clientHeight < 120
switch (frame.event) {
- case 'message.start': stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break
+ case 'message.start': turnGeneration++; stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break
case 'message.delta':
stream ||= bubble('assistant', '', true)
stream.textContent = (stream.textContent + frame.text).slice(-100000)
@@ -169,14 +178,17 @@ $('composer').addEventListener('submit', async event => {
const text = composePrompt(draft, attachment)
const intent = generation, sessionId = selected
sending = true; controls(); notice(''); activity('IDE’ye gönderiliyor…')
+ const optimistic = bubble('user', text); scrollEnd()
try {
await request('message.send', { sessionId, text })
if (intent !== generation) return
// Keep server history authoritative; optimistic text is replaced on finish.
- bubble('user', text); scrollEnd()
$('message').value = ''; attachment = null; $('attachment').hidden = true
$('stop').hidden = false; activity('Mesaj IDE’ye iletildi; iş sürüyorsa sıraya alınır.')
- } catch (error) { notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.') }
+ } catch (error) {
+ optimistic.parentElement?.remove()
+ notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.')
+ }
finally { sending = false; controls() }
})
$('message').addEventListener('input', controls)
diff --git a/apps/chrome/tests/bridge.test.mjs b/apps/chrome/tests/bridge.test.mjs
index f3870e0..41a78fc 100644
--- a/apps/chrome/tests/bridge.test.mjs
+++ b/apps/chrome/tests/bridge.test.mjs
@@ -147,11 +147,13 @@ test('Windows named pipe requires separate capability, native host frames Turkis
const stop = await startChromeBridge({ directory, resolveBackend: f.resolve })
t.after(stop)
const discovery = JSON.parse(await readFile(join(directory, 'chrome-bridge.json'), 'utf8'))
- const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`)
- await once(bad, 'connect')
- const closed = once(bad, 'close')
- bad.write(JSON.stringify({ token: 'x'.repeat(64) }) + '\n')
- await closed
+ for (const hello of [null, { token: 'x'.repeat(64) }, { token: 'ı'.repeat(64) }, {}]) {
+ const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`)
+ await once(bad, 'connect')
+ const closed = once(bad, 'close')
+ bad.write(JSON.stringify(hello) + '\n')
+ await closed
+ }
const child = spawn(process.env.AITURK_TEST_NATIVE_HOST,
['chrome-extension://ikcplfhgapaabedillejmggjcabpkolp/'],
{ env: { ...process.env, LOCALAPPDATA: local }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
diff --git a/apps/chrome/tests/panel.test.mjs b/apps/chrome/tests/panel.test.mjs
new file mode 100644
index 0000000..6eb6ca1
--- /dev/null
+++ b/apps/chrome/tests/panel.test.mjs
@@ -0,0 +1,103 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { test } from 'node:test'
+import { capturePageContext } from '../extension/page-context.js'
+
+const { JSDOM } = createRequire(import.meta.url)('jsdom')
+const eventHub = () => ({ listeners: [], addListener(callback) { this.listeners.push(callback) }, emit(value) { this.listeners.forEach(callback => callback(value)) } })
+const tick = () => new Promise(resolve => setImmediate(resolve))
+async function settle(condition) {
+ for (let count = 0; count < 100; count++) { if (condition()) return; await tick() }
+ assert.ok(condition(), 'UI did not settle')
+}
+
+async function panel(t, respond) {
+ const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8')
+ const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' })
+ const calls = []
+ const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) {
+ calls.push(message)
+ queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port))
+ } }
+ Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option,
+ chrome: { runtime: { connectNative: () => port }, storage: { local: { get: async () => ({}), set: async () => {} } } } })
+ await import(`../extension/panel.js?test=${Math.random()}`)
+ t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] })
+ const get = id => dom.window.document.getElementById(id)
+ await settle(() => !get('sessions').disabled)
+ return { dom, calls, port, get, async select(id) {
+ get('sessions').value = id
+ get('sessions').dispatchEvent(new dom.window.Event('change'))
+ await settle(() => !get('sessions').disabled)
+ } }
+}
+
+function fixtureResponse(message, emit) {
+ const result = message.action === 'status' ? { profile: 'default' }
+ : message.action === 'sessions.list' ? { profile: 'default', sessions: [{ id: 'one', title: 'Birinci iş' }, { id: 'two', title: 'İkinci iş' }] }
+ : message.action === 'session.open' ? { messages: [{ role: 'user', text: ' önceden yazıldı' }], model: 'IDE modeli' }
+ : { messages: [] }
+ emit({ id: message.id, result })
+}
+
+test('panel safely renders real history and puts the submitted user before early streaming tokens', async t => {
+ const p = await panel(t, (message, emit) => {
+ if (message.action !== 'message.send') return fixtureResponse(message, emit)
+ emit({ event: 'message.start', sessionId: 'one' })
+ emit({ event: 'message.delta', sessionId: 'one', text: 'İlk yanıt' })
+ emit({ id: message.id, result: { accepted: true } })
+ })
+ await p.select('one')
+ assert.equal(p.get('transcript').querySelector('img'), null)
+ assert.ok(p.get('transcript').textContent.includes('
p.get('message').value === '')
+ const messages = [...p.get('transcript').querySelectorAll('.text')].map(node => node.textContent)
+ assert.deepEqual(messages.slice(-2), ['Buradan devam edelim', 'İlk yanıt'])
+ assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
+ assert.equal(p.calls.find(call => call.action === 'message.send').params.sessionId, 'one')
+})
+
+test('task switching drops old events; disconnect keeps draft and does not retry a mutation', async t => {
+ const p = await panel(t, (message, emit, port) => {
+ if (message.action === 'message.send') { port.onDisconnect.emit(); return }
+ fixtureResponse(message, emit)
+ })
+ await p.select('one')
+ await p.select('two')
+ p.port.onMessage.emit({ event: 'message.delta', sessionId: 'one', text: 'YANLIŞ KONUŞMA' })
+ assert.ok(!p.get('transcript').textContent.includes('YANLIŞ'))
+ p.get('message').value = 'Kaybolmasın'
+ p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
+ await settle(() => p.get('notice').textContent.includes('Taslağınız korundu'))
+ assert.equal(p.get('message').value, 'Kaybolmasın')
+ assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
+ assert.equal(p.get('send').disabled, true)
+})
+
+test('events arriving while history opens are not silently lost', async t => {
+ const p = await panel(t, (message, emit) => {
+ if (message.action === 'session.open') {
+ emit({ event: 'message.start', sessionId: message.params.sessionId })
+ emit({ event: 'message.delta', sessionId: message.params.sessionId, text: 'Açılırken gelen yanıt' })
+ }
+ fixtureResponse(message, emit)
+ })
+ await p.select('one')
+ assert.ok(p.get('transcript').textContent.includes('Açılırken gelen yanıt'))
+})
+
+test('explicit page capture excludes form values and strips URL credentials in query and fragment', t => {
+ const dom = new JSDOM('
Görünür içerik