Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ Thumbs.db
*.keystore
keystore.properties
secrets.properties

# Node
node_modules/
playwright-report/
test-results/
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -60,7 +63,19 @@ fun ChatScreen(viewModel: AppViewModel, modifier: Modifier = Modifier) {
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message AI...") }
placeholder = { Text("Message AI...") },
singleLine = true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Setting singleLine to true changes the chat composer from a multiline field to a one-line field, so users cannot enter multiline prompts or preserve newline characters in messages. Keep the field multiline while configuring the IME action separately, or explicitly support multiline input as part of the chat contract. [behavioral regression]

Severity Level: Major ⚠️
- ⚠️ Chat users cannot compose multiline prompts.
- ⚠️ Newline formatting is unavailable in sent messages.
- ⚠️ Code blocks and structured prompts become harder to enter.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/src/main/java/com/sayanthrock/freeairock/ui/chat/ChatScreen.kt
**Line:** 67:67
**Comment:**
	*Behavioral Regression: Setting `singleLine` to true changes the chat composer from a multiline field to a one-line field, so users cannot enter multiline prompts or preserve newline characters in messages. Keep the field multiline while configuring the IME action separately, or explicitly support multiline input as part of the chat contract.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Send
),
keyboardActions = KeyboardActions(
onSend = {
if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText)
inputText = ""
}
}
)
)
Button(
onClick = {
Expand All @@ -69,6 +84,7 @@ fun ChatScreen(viewModel: AppViewModel, modifier: Modifier = Modifier) {
inputText = ""
}
},
enabled = inputText.isNotBlank(),
modifier = Modifier.padding(start = 8.dp)
) {
Text("Send")
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
function history(){let q=$('#search').value.toLowerCase();$('#history').innerHTML='';let list=chats.filter(c=>c.title.toLowerCase().includes(q));if(!list.length){$('#history').innerHTML='<p style="color:var(--muted);padding:8px">No conversations yet.</p>';return}list.forEach(c=>{let b=document.createElement('button');b.className='chat '+(c.id===id?'active':'');b.innerHTML='<span>◇</span><span>'+esc(c.title)+'</span><span class="del">×</span>';b.onclick=e=>{if(e.target.classList.contains('del')){chats=chats.filter(x=>x.id!==c.id);if(id===c.id)id=null;save();draw();history();return}id=c.id;draw();history();$('#sidebar').classList.remove('open')};$('#history').append(b)})}
function draw(){let c=active();$('#title').textContent=c?.title||'New conversation';let box=$('#messages');box.innerHTML='';if(!c||!c.messages.length){box.innerHTML='<div class="empty"><div class="eyebrow">Powerful AI. Simple experience.</div><h2>Ask better questions.<br>Build better things.</h2><p>Connect a provider in Settings, then start a conversation.</p><div class="suggestions"><button class="suggestion">Explain a complex idea</button><button class="suggestion">Review my code</button><button class="suggestion">Plan a project</button></div></div>';box.querySelectorAll('.suggestion').forEach(b=>b.onclick=()=>{$('#prompt').value=b.textContent;$('#prompt').focus()});return}c.messages.forEach((m,i)=>{let r=document.createElement('article');r.className='message '+m.role;r.innerHTML=(m.role==='assistant'?'<div class="avatar">R</div>':'')+'<div><div class="bubble">'+md(m.content)+'</div>'+(m.role==='assistant'?'<div class="tools"><button data-copy="'+i+'">Copy</button><button data-retry="'+i+'">Regenerate</button></div>':'')+'</div>';box.append(r)});box.scrollTop=box.scrollHeight;box.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>navigator.clipboard.writeText(c.messages[+b.dataset.copy].content).then(()=>toast('Copied')));box.querySelectorAll('[data-retry]').forEach(b=>b.onclick=()=>send(c.messages[+b.dataset.retry-1]?.content||'',true))}
function newChat(){id=null;draw();history();$('#prompt').focus()}function ensure(text){if(!id){let c={id:uid(),title:text.slice(0,42)+(text.length>42?'…':''),messages:[],createdAt:Date.now()};chats.unshift(c);id=c.id}return active()}
async function send(text,retry=false){if(controller||!text.trim())return;let c=ensure(text);if(!retry)c.messages.push({role:'user',content:text});else c.messages=c.messages.slice(0,-1);let answer={role:'assistant',content:''};c.messages.push(answer);$('#prompt').value='';draw();history();controller=new AbortController();$('#send').textContent='Stop ■';$('#send').classList.add('stop');status('Generating…');try{if(!cfg.apiKey)throw Error('Add an API key and endpoint in Settings first.');let r=await fetch(cfg.endpoint||'https://api.openai.com/v1/chat/completions',{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+cfg.apiKey},body:JSON.stringify({model:$('#model').value,messages:c.messages.filter(m=>m.content).map(m=>({role:m.role,content:m.content})),stream:true})});if(!r.ok)throw Error('Provider returned '+r.status+'. Check your key, model, or endpoint.');let reader=r.body?.getReader(),decoder=new TextDecoder(),buffer='';if(!reader)throw Error('Provider did not return a readable stream.');while(true){let {done,value}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let lines=buffer.split('\\n');buffer=lines.pop();for(let line of lines){if(!line.startsWith('data:'))continue;let payload=line.slice(5).trim();if(payload==='[DONE]')break;try{let d=JSON.parse(payload),delta=d.choices?.[0]?.delta?.content||d.candidates?.[0]?.content?.parts?.[0]?.text||'';if(delta){answer.content+=delta;draw()}}catch{}}}status('');}catch(e){if(e.name==='AbortError'){status('Generation stopped')}else{answer.content='Could not generate a response. '+e.message;status('Provider error',true)}}finally{controller=null;$('#send').textContent='Send ↑';$('#send').classList.remove('stop');save();draw();history()}}
async function send(text,retry=false){if(controller||!text.trim())return;let c=ensure(text);if(!retry)c.messages.push({role:'user',content:text});else c.messages=c.messages.slice(0,-1);let answer={role:'assistant',content:''};c.messages.push(answer);$('#prompt').value='';draw();history();controller=new AbortController();$('#send').textContent='Stop ■';$('#send').classList.add('stop');status('Generating…');try{if(!cfg.apiKey)throw Error('Add an API key and endpoint in Settings first.');let r=await fetch(cfg.endpoint||'https://api.openai.com/v1/chat/completions',{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+cfg.apiKey},body:JSON.stringify({model:$('#model').value,messages:c.messages.filter(m=>m.content).map(m=>({role:m.role,content:m.content})),stream:true})});if(!r.ok)throw Error('Provider returned '+r.status+'. Check your key, model, or endpoint.');let reader=r.body?.getReader(),decoder=new TextDecoder(),buffer='';if(!reader)throw Error('Provider did not return a readable stream.');while(true){let {done,value}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let lines=buffer.split('\n');buffer=lines.pop();for(let line of lines){if(!line.startsWith('data:'))continue;let payload=line.slice(5).trim();if(payload==='[DONE]')break;try{let d=JSON.parse(payload),delta=d.choices?.[0]?.delta?.content||d.candidates?.[0]?.content?.parts?.[0]?.text||'';if(delta){answer.content+=delta;draw()}}catch{}}}status('');}catch(e){if(e.name==='AbortError'){status('Generation stopped')}else{answer.content='Could not generate a response. '+e.message;status('Provider error',true)}}finally{controller=null;$('#send').textContent='Send ↑';$('#send').classList.remove('stop');save();draw();history()}}
$('#form').onsubmit=e=>{e.preventDefault();if(controller)controller.abort();else send($('#prompt').value)};$('#prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('#form').requestSubmit()}};$('#new').onclick=newChat;$('#search').oninput=history;$('#open').onclick=()=>$('#sidebar').classList.add('open');$('#close').onclick=()=>$('#sidebar').classList.remove('open');function openSettings(){ $('#endpoint').value=cfg.endpoint;$('#key').value=cfg.apiKey;$('#theme').value=cfg.theme;$('#modal').classList.add('open')}$('#settings').onclick=$('#topSettings').onclick=openSettings;$('#closeSettings').onclick=$('#cancel').onclick=()=>$('#modal').classList.remove('open');$('#saveSettings').onclick=()=>{cfg={endpoint:$('#endpoint').value.trim(),apiKey:$('#key').value.trim(),theme:$('#theme').value};localStorage.setItem(CFG,JSON.stringify(cfg));applyTheme();$('#modal').classList.remove('open');toast('Settings saved locally')};$('#theme').onchange=applyTheme;function applyTheme(){let t=cfg.theme==='system'?(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):cfg.theme;document.documentElement.dataset.theme=t}$('#rename').onclick=()=>{let c=active();if(!c)return;let n=prompt('Conversation name',c.title);if(n?.trim()){c.title=n.trim();save();draw();history()}};$('#export').onclick=()=>{let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(chats,null,2)],{type:'application/json'}));a.download='free-ai-rock-chats.json';a.click()};$('#clear').onclick=()=>{if(confirm('Delete all saved conversations?')){chats=[];id=null;save();draw();history();$('#modal').classList.remove('open')}};applyTheme();history();draw();
</script></body></html>
87 changes: 73 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions tests/browser-smoke.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,32 @@ test.describe('FREE AI ROCK browser smoke', () => {
test('switches dark, light, and system themes', async ({ page }) => {
await seedSettings(page);
await page.goto(appUrl);

const isMobile = await page.locator('#open').isVisible();
const sidebar = page.locator('#sidebar');

const ensureSidebarOpen = async () => {
if (isMobile) {
const isOpen = await sidebar.evaluate(el => el.classList.contains('open'));
if (!isOpen) {
await page.locator('#open').click();
}
}
};

await ensureSidebarOpen();
await page.locator('#settings').click();
await page.locator('#theme').selectOption('light');
await page.locator('#saveSettings').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');

await ensureSidebarOpen();
await page.locator('#settings').click();
await page.locator('#theme').selectOption('dark');
await page.locator('#saveSettings').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');

await ensureSidebarOpen();
await page.locator('#settings').click();
await page.locator('#theme').selectOption('system');
await page.locator('#saveSettings').click();
Expand Down
Loading